From 1bfa151c6323b98aae5bd10f89084a9ff4a86f13 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Thu, 8 May 2025 15:50:00 +0200 Subject: [PATCH 1/2] feat(ticket): WIP --- ...cad6802fe46500692fa26cb2cc3d693e76ece.json | 46 +++++++++ api/src/api/tickets.rs | 59 ++++++++--- api/src/api/types.rs | 41 ++++++++ api/src/db/database.rs | 38 +++++++ api/src/db/models.rs | 6 +- frontend/routes/ticket.tsx | 98 ++++++++++++------- frontend/utils/api_types.ts | 36 +++++-- 7 files changed, 265 insertions(+), 59 deletions(-) create mode 100644 api/.sqlx/query-2ab4e62ffc27c4ead74a73ff378cad6802fe46500692fa26cb2cc3d693e76ece.json diff --git a/api/.sqlx/query-2ab4e62ffc27c4ead74a73ff378cad6802fe46500692fa26cb2cc3d693e76ece.json b/api/.sqlx/query-2ab4e62ffc27c4ead74a73ff378cad6802fe46500692fa26cb2cc3d693e76ece.json new file mode 100644 index 00000000..1e132ea4 --- /dev/null +++ b/api/.sqlx/query-2ab4e62ffc27c4ead74a73ff378cad6802fe46500692fa26cb2cc3d693e76ece.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n audit_logs.actor_id as \"audit_actor_id\",\n audit_logs.is_sudo as \"audit_is_sudo\",\n audit_logs.action as \"audit_action\",\n audit_logs.meta as \"audit_meta\",\n audit_logs.created_at\n FROM\n audit_logs\n WHERE \n audit_logs.meta::text LIKE $1;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "audit_actor_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "audit_is_sudo", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "audit_action", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "audit_meta", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "2ab4e62ffc27c4ead74a73ff378cad6802fe46500692fa26cb2cc3d693e76ece" +} diff --git a/api/src/api/tickets.rs b/api/src/api/tickets.rs index 25ed1443..2b1a0e6a 100644 --- a/api/src/api/tickets.rs +++ b/api/src/api/tickets.rs @@ -23,7 +23,9 @@ use crate::RegistryUrl; use super::ApiError; use super::ApiTicket; +use super::ApiTicketOverview; use super::ApiTicketMessage; +use super::ApiTicketMessageOrAuditLog; pub fn tickets_router() -> Router { Router::builder() @@ -35,21 +37,54 @@ pub fn tickets_router() -> Router { } #[instrument(name = "GET /api/tickets/:id", skip(req), err, fields(id))] -pub async fn get_handler(req: Request) -> ApiResult { - let id = req.param_uuid("id")?; - Span::current().record("id", field::display(id)); +pub async fn get_handler(req: Request) -> ApiResult { + let id = match req.param_uuid("id") { + Ok(id) => id, + Err(_) => return Err(ApiError::MalformedRequest { msg: "Invalid ID".into() }), + }; + Span::current().record("id", field::display(id)); - let db = req.data::().unwrap(); - let ticket = db.get_ticket(id).await?.ok_or(ApiError::TicketNotFound)?; + let db = match req.data::() { + Some(db) => db, + None => return Err(ApiError::InternalServerError), + }; - let iam = req.iam(); + let ticket = match db.get_ticket(id).await { + Ok(Some(ticket)) => ticket, + Ok(None) => return Err(ApiError::TicketNotFound), + Err(_) => return Err(ApiError::InternalServerError), + }; - let current_user = iam.check_current_user_access()?; - if current_user == &ticket.1 || iam.check_admin_access().is_ok() { - Ok(ticket.into()) - } else { - Err(ApiError::TicketNotFound) - } + let ticket_audit = db.get_ticket_audit_logs(id).await; + + let iam = req.iam(); + let current_user = iam.check_current_user_access()?; + + if current_user == &ticket.1 || iam.check_admin_access().is_ok() { + let mut events: Vec = Vec::new(); + + for message in ticket.2 { + events.push(ApiTicketMessageOrAuditLog::Message { + message: message.0, + user: message.1, + }); + } + + if let Ok(audit_logs) = ticket_audit { + for audit_log in audit_logs { + events.push(ApiTicketMessageOrAuditLog::AuditLog(audit_log)); + } + } + + events.sort_by_key(|event| match event { + ApiTicketMessageOrAuditLog::Message { message, .. } => message.created_at, + ApiTicketMessageOrAuditLog::AuditLog(log) => log.created_at, + }); + + Ok((ticket.0, ticket.1, events).into()) + } else { + Err(ApiError::TicketNotFound) + } } #[instrument(name = "POST /api/tickets", skip(req), err)] diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 4157f973..605ae6f4 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -989,6 +989,47 @@ pub struct ApiPackageDownloadsRecentVersion { pub downloads: Vec, } + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum ApiTicketMessageOrAuditLog { + Message { + message: TicketMessage, + user: UserPublic, + }, + AuditLog(AuditLog), +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApiTicketOverview { + pub id: Uuid, + pub kind: TicketKind, + pub creator: ApiUser, + pub meta: serde_json::Value, + pub closed: bool, + pub events: Vec, + pub updated_at: DateTime, + pub created_at: DateTime, +} + +impl From<(Ticket, User, Vec)> for ApiTicketOverview { + fn from( + (value, user, events): (Ticket, User, Vec), + ) -> Self { + Self { + id: value.id, + kind: value.kind, + creator: user.into(), + meta: value.meta, + closed: value.closed, + events: events, + updated_at: value.updated_at, + created_at: value.created_at, + } + } +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApiTicket { diff --git a/api/src/db/database.rs b/api/src/db/database.rs index 87026fc3..64d146ad 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -4453,6 +4453,44 @@ impl Database { Ok(Some((ticket, user, messages))) } + #[instrument(name = "Database::get_ticket_audit_logs", skip(self), err)] + pub async fn get_ticket_audit_logs( + &self, + ticket_id: Uuid, + ) -> Result> { + let mut tx = self.pool.begin().await?; + + let audit_logs = sqlx::query!( + r#" + SELECT + audit_logs.actor_id as "audit_actor_id", + audit_logs.is_sudo as "audit_is_sudo", + audit_logs.action as "audit_action", + audit_logs.meta as "audit_meta", + audit_logs.created_at + FROM + audit_logs + WHERE + audit_logs.meta::text LIKE $1 + ORDER BY audit_logs.created_at DESC; + "#, + format!("%\"ticket_id\": \"{}\"%", ticket_id), + ) + .map(|r| AuditLog { + actor_id: r.audit_actor_id, + is_sudo: r.audit_is_sudo, + action: r.audit_action, + meta: r.audit_meta, + created_at: r.created_at, + }) + .fetch_all(&mut *tx) + .await?; + + tx.commit().await?; + + Ok(audit_logs) + } + #[instrument(name = "Database::ticket_add_message", skip(self), err)] pub async fn ticket_add_message( &self, diff --git a/api/src/db/models.rs b/api/src/db/models.rs index 6c722ff6..3a926ecc 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -59,7 +59,7 @@ impl FromRow<'_, sqlx::postgres::PgRow> for User { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserPublic { pub id: Uuid, pub name: String, @@ -975,7 +975,7 @@ pub struct NewTicketMessage { pub message: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TicketMessage { pub ticket_id: Uuid, pub author: Uuid, @@ -986,7 +986,7 @@ pub struct TicketMessage { pub type FullTicket = (Ticket, User, Vec<(TicketMessage, UserPublic)>); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditLog { pub actor_id: Uuid, pub is_sudo: bool, diff --git a/frontend/routes/ticket.tsx b/frontend/routes/ticket.tsx index ac314627..8bdd11be 100644 --- a/frontend/routes/ticket.tsx +++ b/frontend/routes/ticket.tsx @@ -1,12 +1,12 @@ // Copyright 2024 the JSR authors. All rights reserved. MIT license. import { HttpError, RouteConfig } from "fresh"; +import { TbArrowLeft, TbCheck, TbClock } from "tb-icons"; +import twas from "twas"; import { define } from "../util.ts"; -import type { Ticket, TicketKind } from "../utils/api_types.ts"; import { path } from "../utils/api.ts"; -import twas from "twas"; import { TicketMessageInput } from "../islands/TicketMessageInput.tsx"; -import { TbArrowLeft, TbCheck, TbClock } from "tb-icons"; import { TicketTitle } from "../components/TicketTitle.tsx"; +import type { Ticket, TicketKind } from "../utils/api_types.ts"; export default define.page(function Ticket({ data, @@ -66,42 +66,68 @@ export default define.page(function Ticket({
- {data.ticket.messages.map((message) => { - const isOpener = message.author.id === data.ticket.creator.id; - return ( -
-
-
- - {message.author.name} - {message.author.name} - {" "} - - - {isOpener ? "User" : "Staff"} - + {data.ticket.events.map((event) => { + if (event.kind === "message") { + const { message, user } = event; + const isOpener = user.id === data.ticket.creator.id; + + return ( +
+
+
+ + {user.name} + {user.name} + {" "} + + + {isOpener ? "User" : "Staff"} + +
+
+ {twas(new Date(message.updated_at).getTime())} +
-
- {twas(new Date(message.createdAt).getTime())} +
+                {message.message}
+                
+
+ ); + } else { + const log = event; + + return ( +
+
+ {data.ticket.closed + ? + : }
+

+ {log.actor_id}{" "} + {log.meta.closed ? "closed the ticket" : "opened the ticket"} +

-
-                {message.message}
-              
-
- ); + ); + } })}
{state.user!.id === data.ticket.creator.id && diff --git a/frontend/utils/api_types.ts b/frontend/utils/api_types.ts index fff2f25b..66704010 100644 --- a/frontend/utils/api_types.ts +++ b/frontend/utils/api_types.ts @@ -327,14 +327,34 @@ export interface Ticket { creator: User; meta: Record; closed: boolean; - messages: TicketMessage[]; - updatedAt: string; - createdAt: string; -} - -export interface TicketMessage { - author: User; - message: string; + events: Array< + | { + kind: "message"; + message: { + ticket_id: string; + author: string; + message: string; + updated_at: string; + created_at: string; + }; + user: { + id: string; + name: string; + avatar_url: string; + github_id: number; + updated_at: string; + created_at: string; + }; + } + | { + kind: "auditLog"; + actor_id: string; + is_sudo: boolean; + action: string; + meta: Record; + created_at: string; + } + >; updatedAt: string; createdAt: string; } From 75226c7c3fd03f19b7ebcd3b5cc8f16586bc5dc8 Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Thu, 8 May 2025 17:10:39 +0200 Subject: [PATCH 2/2] feat(ticket): add historic of changes --- ...f551649fe57c3ee47629d9c62455c7ee3e764.json | 82 ++++++++++++++++ ...cad6802fe46500692fa26cb2cc3d693e76ece.json | 46 --------- api/src/api/tickets.rs | 95 ++++++++++--------- api/src/api/types.rs | 21 ++-- api/src/db/database.rs | 39 ++++++-- api/src/db/models.rs | 3 + frontend/islands/TicketMessageInput.tsx | 15 +-- frontend/islands/TicketModal.tsx | 6 +- frontend/routes/account/tickets.tsx | 4 +- frontend/routes/admin/tickets.tsx | 4 +- frontend/routes/ticket.tsx | 25 +++-- frontend/utils/api_types.ts | 69 ++++++++------ 12 files changed, 251 insertions(+), 158 deletions(-) create mode 100644 api/.sqlx/query-1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764.json delete mode 100644 api/.sqlx/query-2ab4e62ffc27c4ead74a73ff378cad6802fe46500692fa26cb2cc3d693e76ece.json diff --git a/api/.sqlx/query-1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764.json b/api/.sqlx/query-1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764.json new file mode 100644 index 00000000..896fca8b --- /dev/null +++ b/api/.sqlx/query-1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n audit_logs.actor_id as \"audit_actor_id\",\n audit_logs.is_sudo as \"audit_is_sudo\",\n audit_logs.action as \"audit_action\",\n audit_logs.meta as \"audit_meta\",\n audit_logs.created_at as \"audit_created_at\",\n users.id as \"user_id\",\n users.name as \"user_name\",\n users.avatar_url as \"user_avatar_url\",\n users.github_id as \"user_github_id\",\n users.updated_at as \"user_updated_at\",\n users.created_at as \"user_created_at\"\n FROM\n audit_logs\n LEFT JOIN\n users ON audit_logs.actor_id = users.id\n WHERE \n audit_logs.meta::text LIKE $1\n ORDER BY audit_logs.created_at DESC;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "audit_actor_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "audit_is_sudo", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "audit_action", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "audit_meta", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "audit_created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "user_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "user_avatar_url", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "user_github_id", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "user_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "user_created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764" +} diff --git a/api/.sqlx/query-2ab4e62ffc27c4ead74a73ff378cad6802fe46500692fa26cb2cc3d693e76ece.json b/api/.sqlx/query-2ab4e62ffc27c4ead74a73ff378cad6802fe46500692fa26cb2cc3d693e76ece.json deleted file mode 100644 index 1e132ea4..00000000 --- a/api/.sqlx/query-2ab4e62ffc27c4ead74a73ff378cad6802fe46500692fa26cb2cc3d693e76ece.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n audit_logs.actor_id as \"audit_actor_id\",\n audit_logs.is_sudo as \"audit_is_sudo\",\n audit_logs.action as \"audit_action\",\n audit_logs.meta as \"audit_meta\",\n audit_logs.created_at\n FROM\n audit_logs\n WHERE \n audit_logs.meta::text LIKE $1;\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "audit_actor_id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "audit_is_sudo", - "type_info": "Bool" - }, - { - "ordinal": 2, - "name": "audit_action", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "audit_meta", - "type_info": "Jsonb" - }, - { - "ordinal": 4, - "name": "created_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "2ab4e62ffc27c4ead74a73ff378cad6802fe46500692fa26cb2cc3d693e76ece" -} diff --git a/api/src/api/tickets.rs b/api/src/api/tickets.rs index 2b1a0e6a..2a4f71db 100644 --- a/api/src/api/tickets.rs +++ b/api/src/api/tickets.rs @@ -23,9 +23,9 @@ use crate::RegistryUrl; use super::ApiError; use super::ApiTicket; -use super::ApiTicketOverview; use super::ApiTicketMessage; use super::ApiTicketMessageOrAuditLog; +use super::ApiTicketOverview; pub fn tickets_router() -> Router { Router::builder() @@ -38,53 +38,62 @@ pub fn tickets_router() -> Router { #[instrument(name = "GET /api/tickets/:id", skip(req), err, fields(id))] pub async fn get_handler(req: Request) -> ApiResult { - let id = match req.param_uuid("id") { - Ok(id) => id, - Err(_) => return Err(ApiError::MalformedRequest { msg: "Invalid ID".into() }), - }; - Span::current().record("id", field::display(id)); - - let db = match req.data::() { - Some(db) => db, - None => return Err(ApiError::InternalServerError), - }; - - let ticket = match db.get_ticket(id).await { - Ok(Some(ticket)) => ticket, - Ok(None) => return Err(ApiError::TicketNotFound), - Err(_) => return Err(ApiError::InternalServerError), - }; - - let ticket_audit = db.get_ticket_audit_logs(id).await; - - let iam = req.iam(); - let current_user = iam.check_current_user_access()?; - - if current_user == &ticket.1 || iam.check_admin_access().is_ok() { - let mut events: Vec = Vec::new(); - - for message in ticket.2 { - events.push(ApiTicketMessageOrAuditLog::Message { - message: message.0, - user: message.1, - }); - } + let id = match req.param_uuid("id") { + Ok(id) => id, + Err(_) => { + return Err(ApiError::MalformedRequest { + msg: "Invalid ID".into(), + }) + } + }; + Span::current().record("id", field::display(id)); - if let Ok(audit_logs) = ticket_audit { - for audit_log in audit_logs { - events.push(ApiTicketMessageOrAuditLog::AuditLog(audit_log)); - } - } + let db = match req.data::() { + Some(db) => db, + None => return Err(ApiError::InternalServerError), + }; - events.sort_by_key(|event| match event { - ApiTicketMessageOrAuditLog::Message { message, .. } => message.created_at, - ApiTicketMessageOrAuditLog::AuditLog(log) => log.created_at, + let ticket = match db.get_ticket(id).await { + Ok(Some(ticket)) => ticket, + Ok(None) => return Err(ApiError::TicketNotFound), + Err(_) => return Err(ApiError::InternalServerError), + }; + + let ticket_audit = db.get_ticket_audit_logs(id).await; + + let iam = req.iam(); + let current_user = iam.check_current_user_access()?; + + if current_user == &ticket.1 || iam.check_admin_access().is_ok() { + let mut events: Vec = Vec::new(); + + for message in ticket.2 { + events.push(ApiTicketMessageOrAuditLog::Message { + message: message.0, + user: message.1, }); + } - Ok((ticket.0, ticket.1, events).into()) - } else { - Err(ApiError::TicketNotFound) + if let Ok(audit_logs) = ticket_audit { + for audit_log in audit_logs { + events.push(ApiTicketMessageOrAuditLog::AuditLog { + audit_log: audit_log.0, + user: audit_log.1, + }); + } } + + events.sort_by_key(|event| match event { + ApiTicketMessageOrAuditLog::Message { message, .. } => message.created_at, + ApiTicketMessageOrAuditLog::AuditLog { audit_log, .. } => { + audit_log.created_at + } + }); + + Ok((ticket.0, ticket.1, events).into()) + } else { + Err(ApiError::TicketNotFound) + } } #[instrument(name = "POST /api/tickets", skip(req), err)] diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 605ae6f4..99579c21 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -989,15 +989,18 @@ pub struct ApiPackageDownloadsRecentVersion { pub downloads: Vec, } - #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase", tag = "kind")] pub enum ApiTicketMessageOrAuditLog { - Message { - message: TicketMessage, - user: UserPublic, - }, - AuditLog(AuditLog), + Message { + message: TicketMessage, + user: UserPublic, + }, + #[serde(rename_all = "camelCase")] + AuditLog { + audit_log: AuditLog, + user: UserPublic, + }, } #[derive(Debug, Serialize, Deserialize)] @@ -1013,7 +1016,9 @@ pub struct ApiTicketOverview { pub created_at: DateTime, } -impl From<(Ticket, User, Vec)> for ApiTicketOverview { +impl From<(Ticket, User, Vec)> + for ApiTicketOverview +{ fn from( (value, user, events): (Ticket, User, Vec), ) -> Self { @@ -1023,7 +1028,7 @@ impl From<(Ticket, User, Vec)> for ApiTicketOverview creator: user.into(), meta: value.meta, closed: value.closed, - events: events, + events, updated_at: value.updated_at, created_at: value.created_at, } diff --git a/api/src/db/database.rs b/api/src/db/database.rs index 64d146ad..97124964 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -4454,10 +4454,10 @@ impl Database { } #[instrument(name = "Database::get_ticket_audit_logs", skip(self), err)] - pub async fn get_ticket_audit_logs( + pub async fn get_ticket_audit_logs( &self, ticket_id: Uuid, - ) -> Result> { + ) -> Result> { let mut tx = self.pool.begin().await?; let audit_logs = sqlx::query!( @@ -4467,30 +4467,51 @@ impl Database { audit_logs.is_sudo as "audit_is_sudo", audit_logs.action as "audit_action", audit_logs.meta as "audit_meta", - audit_logs.created_at + audit_logs.created_at as "audit_created_at", + users.id as "user_id", + users.name as "user_name", + users.avatar_url as "user_avatar_url", + users.github_id as "user_github_id", + users.updated_at as "user_updated_at", + users.created_at as "user_created_at" FROM audit_logs + LEFT JOIN + users ON audit_logs.actor_id = users.id WHERE audit_logs.meta::text LIKE $1 ORDER BY audit_logs.created_at DESC; "#, format!("%\"ticket_id\": \"{}\"%", ticket_id), ) - .map(|r| AuditLog { + .map(|r| { + let audit_log = AuditLog { actor_id: r.audit_actor_id, is_sudo: r.audit_is_sudo, action: r.audit_action, meta: r.audit_meta, - created_at: r.created_at, - }) - .fetch_all(&mut *tx) - .await?; + created_at: r.audit_created_at, + }; + + let user = UserPublic { + id: r.user_id, + name: r.user_name, + avatar_url: r.user_avatar_url, + github_id: r.user_github_id, + updated_at: r.user_updated_at, + created_at: r.user_created_at, + }; + + (audit_log, user) + }) + .fetch_all(&mut *tx) + .await?; tx.commit().await?; Ok(audit_logs) } - + #[instrument(name = "Database::ticket_add_message", skip(self), err)] pub async fn ticket_add_message( &self, diff --git a/api/src/db/models.rs b/api/src/db/models.rs index 3a926ecc..c557e029 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -60,6 +60,7 @@ impl FromRow<'_, sqlx::postgres::PgRow> for User { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct UserPublic { pub id: Uuid, pub name: String, @@ -976,6 +977,7 @@ pub struct NewTicketMessage { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct TicketMessage { pub ticket_id: Uuid, pub author: Uuid, @@ -987,6 +989,7 @@ pub struct TicketMessage { pub type FullTicket = (Ticket, User, Vec<(TicketMessage, UserPublic)>); #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct AuditLog { pub actor_id: Uuid, pub is_sudo: bool, diff --git a/frontend/islands/TicketMessageInput.tsx b/frontend/islands/TicketMessageInput.tsx index 3ad8226a..70f4dd85 100644 --- a/frontend/islands/TicketMessageInput.tsx +++ b/frontend/islands/TicketMessageInput.tsx @@ -5,12 +5,15 @@ import { AdminUpdateTicketRequest, FullUser, NewTicketMessage, - Ticket, } from "../utils/api_types.ts"; import { api, path } from "../utils/api.ts"; export function TicketMessageInput( - { ticket, user }: { ticket: Ticket; user: FullUser }, + { ticketId, closed, user }: { + ticketId: string; + closed: boolean; + user: FullUser; + }, ) { const [message, setMessage] = useState(""); @@ -21,7 +24,7 @@ export function TicketMessageInput( e.preventDefault(); api.post( - path`/tickets/${ticket.id}`, + path`/tickets/${ticketId}`, { message, } satisfies NewTicketMessage, @@ -52,9 +55,9 @@ export function TicketMessageInput( e.preventDefault(); api.patch( - path`/admin/tickets/${ticket.id}`, + path`/admin/tickets/${ticketId}`, { - closed: !ticket.closed, + closed: !closed, } satisfies AdminUpdateTicketRequest, ).then((resp) => { if (resp.ok) { @@ -66,7 +69,7 @@ export function TicketMessageInput( }); }} > - {ticket.closed + {closed ? ( <> Re-open diff --git a/frontend/islands/TicketModal.tsx b/frontend/islands/TicketModal.tsx index cfdcd77b..67b1e273 100644 --- a/frontend/islands/TicketModal.tsx +++ b/frontend/islands/TicketModal.tsx @@ -1,6 +1,6 @@ // Copyright 2024 the JSR authors. All rights reserved. MIT license. import { useEffect, useId, useRef, useState } from "preact/hooks"; -import { NewTicket, Ticket, TicketKind, User } from "../utils/api_types.ts"; +import { ApiTicket, NewTicket, TicketKind, User } from "../utils/api_types.ts"; import type { ComponentChildren } from "preact"; import { TbLoader2 } from "tb-icons"; import { api, path } from "../utils/api.ts"; @@ -31,7 +31,7 @@ export function TicketModal( const [status, setStatus] = useState<"pending" | "submitting" | "submitted">( "pending", ); - const [ticket, setTicket] = useState(null); + const [ticket, setTicket] = useState(null); const buttonRef = useRef(null); const ref = useRef(null); @@ -111,7 +111,7 @@ export function TicketModal( setStatus("submitting"); - api.post(path`/tickets`, data).then((res) => { + api.post(path`/tickets`, data).then((res) => { if (res.ok) { setStatus("submitted"); setTicket(res.data); diff --git a/frontend/routes/account/tickets.tsx b/frontend/routes/account/tickets.tsx index 95b83a6b..e4c06d48 100644 --- a/frontend/routes/account/tickets.tsx +++ b/frontend/routes/account/tickets.tsx @@ -3,7 +3,7 @@ import { HttpError } from "fresh"; import { AccountLayout } from "./(_components)/AccountLayout.tsx"; import { define } from "../../util.ts"; import { Table, TableData, TableRow } from "../../components/Table.tsx"; -import { Ticket } from "../../utils/api_types.ts"; +import { ApiTicket } from "../../utils/api_types.ts"; import { path } from "../../utils/api.ts"; import twas from "twas"; import { TbCheck, TbClock } from "tb-icons"; @@ -77,7 +77,7 @@ export const handler = define.handlers({ async GET(ctx) { const [currentUser, ticketsRes] = await Promise.all([ ctx.state.userPromise, - ctx.state.api.get(path`/user/tickets`), + ctx.state.api.get(path`/user/tickets`), ]); if (currentUser instanceof Response) return currentUser; if (!currentUser) throw new HttpError(404, "No signed in user found."); diff --git a/frontend/routes/admin/tickets.tsx b/frontend/routes/admin/tickets.tsx index 6ca1fecd..35b68eea 100644 --- a/frontend/routes/admin/tickets.tsx +++ b/frontend/routes/admin/tickets.tsx @@ -3,7 +3,7 @@ import { define } from "../../util.ts"; import { Table, TableData, TableRow } from "../../components/Table.tsx"; import { AdminNav } from "./(_components)/AdminNav.tsx"; import { path } from "../../utils/api.ts"; -import { List, Ticket } from "../../utils/api_types.ts"; +import type { ApiTicket, List } from "../../utils/api_types.ts"; import { URLQuerySearch } from "./(_components)/URLQuerySearch.tsx"; import twas from "twas"; import { TbCheck, TbClock } from "tb-icons"; @@ -117,7 +117,7 @@ export const handler = define.handlers({ const page = +(ctx.url.searchParams.get("page") || 1); const limit = +(ctx.url.searchParams.get("limit") || 20); - const resp = await ctx.state.api.get>( + const resp = await ctx.state.api.get>( path`/admin/tickets`, { query, diff --git a/frontend/routes/ticket.tsx b/frontend/routes/ticket.tsx index 8bdd11be..b69448f2 100644 --- a/frontend/routes/ticket.tsx +++ b/frontend/routes/ticket.tsx @@ -6,7 +6,7 @@ import { define } from "../util.ts"; import { path } from "../utils/api.ts"; import { TicketMessageInput } from "../islands/TicketMessageInput.tsx"; import { TicketTitle } from "../components/TicketTitle.tsx"; -import type { Ticket, TicketKind } from "../utils/api_types.ts"; +import type { ApiTicketOverview, TicketKind } from "../utils/api_types.ts"; export default define.page(function Ticket({ data, @@ -80,7 +80,7 @@ export default define.page(function Ticket({ href={`/user/${message.author}`} > {user.name} @@ -97,7 +97,7 @@ export default define.page(function Ticket({
- {twas(new Date(message.updated_at).getTime())} + {twas(new Date(message.updatedAt).getTime())}
@@ -106,24 +106,25 @@ export default define.page(function Ticket({
               
); } else { - const log = event; + const { user, auditLog } = event; return (
- {data.ticket.closed + {auditLog.meta.closed ? : }

- {log.actor_id}{" "} - {log.meta.closed ? "closed the ticket" : "opened the ticket"} + {user.name}{" "} + {auditLog.meta.closed ? "closed" : "opened"} the ticket{" "} + {twas(new Date(auditLog.createdAt).getTime())}

); @@ -138,7 +139,11 @@ export default define.page(function Ticket({ {state.user!.email} when we respond to your ticket.

)} - + ); }); @@ -171,7 +176,7 @@ export const handler = define.handlers({ async GET(ctx) { const [currentUser, ticketResp] = await Promise.all([ ctx.state.userPromise, - ctx.state.api.get(path`/tickets/${ctx.params.ticket}`), + ctx.state.api.get(path`/tickets/${ctx.params.ticket}`), ]); if (currentUser instanceof Response) return currentUser; if (!currentUser) throw new HttpError(404, "No signed in user found."); diff --git a/frontend/utils/api_types.ts b/frontend/utils/api_types.ts index 66704010..192084e3 100644 --- a/frontend/utils/api_types.ts +++ b/frontend/utils/api_types.ts @@ -321,40 +321,40 @@ export interface NewTicketMessage { message: string; } -export interface Ticket { +export interface ApiTicketMessage { + author: User; + message: string; + updatedAt: string; + createdAt: string; +} + +export interface ApiAuditLog { + actor: User; + action: string; + isSudo: boolean; + meta: Record; + createdAt: string; +} + +export type ApiTicketMessageOrAuditLog = + | { + kind: "message"; + message: ApiTicketMessage; + user: User; + } + | { + kind: "auditLog"; + auditLog: ApiAuditLog; + user: User; + }; + +export interface ApiTicketOverview { id: string; kind: TicketKind; creator: User; meta: Record; closed: boolean; - events: Array< - | { - kind: "message"; - message: { - ticket_id: string; - author: string; - message: string; - updated_at: string; - created_at: string; - }; - user: { - id: string; - name: string; - avatar_url: string; - github_id: number; - updated_at: string; - created_at: string; - }; - } - | { - kind: "auditLog"; - actor_id: string; - is_sudo: boolean; - action: string; - meta: Record; - created_at: string; - } - >; + events: ApiTicketMessageOrAuditLog[]; updatedAt: string; createdAt: string; } @@ -363,6 +363,17 @@ export interface AdminUpdateTicketRequest { closed?: boolean; } +export interface ApiTicket { + id: string; + kind: TicketKind; + creator: User; + meta: Record; + closed: boolean; + messages: ApiTicketMessage[]; + updatedAt: string; + createdAt: string; +} + export interface AuditLog { actor: User; isSudo: boolean;