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}
- {" "}
-
-
- {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 (
+
+
+
+
+ {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;