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}
- {" "}
-
-
- {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.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}`}
>
@@ -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;