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/src/api/tickets.rs b/api/src/api/tickets.rs index 25ed1443..2a4f71db 100644 --- a/api/src/api/tickets.rs +++ b/api/src/api/tickets.rs @@ -24,6 +24,8 @@ use crate::RegistryUrl; use super::ApiError; use super::ApiTicket; use super::ApiTicketMessage; +use super::ApiTicketMessageOrAuditLog; +use super::ApiTicketOverview; pub fn tickets_router() -> Router { Router::builder() @@ -35,18 +37,60 @@ 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")?; +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 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() { - Ok(ticket.into()) + 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: 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) } diff --git a/api/src/api/types.rs b/api/src/api/types.rs index 4157f973..99579c21 100644 --- a/api/src/api/types.rs +++ b/api/src/api/types.rs @@ -989,6 +989,52 @@ pub struct ApiPackageDownloadsRecentVersion { pub downloads: Vec, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum ApiTicketMessageOrAuditLog { + Message { + message: TicketMessage, + user: UserPublic, + }, + #[serde(rename_all = "camelCase")] + AuditLog { + audit_log: AuditLog, + user: UserPublic, + }, +} + +#[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, + 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..97124964 100644 --- a/api/src/db/database.rs +++ b/api/src/db/database.rs @@ -4453,6 +4453,65 @@ 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 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| { + 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.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 6c722ff6..c557e029 100644 --- a/api/src/db/models.rs +++ b/api/src/db/models.rs @@ -59,7 +59,8 @@ impl FromRow<'_, sqlx::postgres::PgRow> for User { } } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct UserPublic { pub id: Uuid, pub name: String, @@ -975,7 +976,8 @@ pub struct NewTicketMessage { pub message: String, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] pub struct TicketMessage { pub ticket_id: Uuid, pub author: Uuid, @@ -986,7 +988,8 @@ pub struct TicketMessage { pub type FullTicket = (Ticket, User, Vec<(TicketMessage, UserPublic)>); -#[derive(Debug, Clone)] +#[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 ac314627..b69448f2 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 { ApiTicketOverview, TicketKind } from "../utils/api_types.ts"; export default define.page(function Ticket({ data, @@ -66,42 +66,69 @@ 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.updatedAt).getTime())} +
-
- {twas(new Date(message.createdAt).getTime())} +
+                {message.message}
+                
+
+ ); + } else { + const { user, auditLog } = event; + + return ( +
+
+ {auditLog.meta.closed + ? + : }
+

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

-
-                {message.message}
-              
-
- ); + ); + } })}
{state.user!.id === data.ticket.creator.id && @@ -112,7 +139,11 @@ export default define.page(function Ticket({ {state.user!.email} when we respond to your ticket.

)} - +
); }); @@ -145,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 fff2f25b..192084e3 100644 --- a/frontend/utils/api_types.ts +++ b/frontend/utils/api_types.ts @@ -321,20 +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; - messages: TicketMessage[]; - updatedAt: string; - createdAt: string; -} - -export interface TicketMessage { - author: User; - message: string; + events: ApiTicketMessageOrAuditLog[]; updatedAt: string; createdAt: string; } @@ -343,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;