Skip to content

Commit bcac39f

Browse files
feat(tickets): add way to track changes (#1102)
## Description Adding a way to track changing state of a tickets <img width="1086" alt="Capture d’écran 2025-05-08 à 16 41 23" src="https://github.com/user-attachments/assets/ba99c573-7ddd-4cb4-9fdc-ef125adf7d20" /> ### PR Checklist - [x] The PR title follows [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/) - [x] Is this closing an open issue? If so, link it, else include a proper description of the changes and rason behind them. - [x] Does the PR have changes to the frontend? If so, include screenshots or a recording of the changes. <br/>If it affect colors, please include screenshots/recording in both light and dark mode. - [x] Does the PR have changes to the backend? If so, make sure tests are added. <br/>And if changing dababase queries, be sure you have ran `sqlx prepare` and committed the changes in the `.sqlx` directory. --------- Co-authored-by: Leo Kettmeir <[email protected]>
1 parent f3c278c commit bcac39f

File tree

11 files changed

+388
-72
lines changed

11 files changed

+388
-72
lines changed

api/.sqlx/query-1a5aa7ce4a5d4e1406709fbe9b9f551649fe57c3ee47629d9c62455c7ee3e764.json

Lines changed: 82 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/src/api/tickets.rs

Lines changed: 70 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ use crate::util::decode_json;
2424
use super::ApiError;
2525
use super::ApiTicket;
2626
use super::ApiTicketMessage;
27+
use super::ApiTicketMessageOrAuditLog;
28+
use super::ApiTicketOverview;
2729

2830
pub fn tickets_router() -> Router<Body, ApiError> {
2931
Router::builder()
@@ -35,18 +37,48 @@ pub fn tickets_router() -> Router<Body, ApiError> {
3537
}
3638

3739
#[instrument(name = "GET /api/tickets/:id", skip(req), err, fields(id))]
38-
pub async fn get_handler(req: Request<Body>) -> ApiResult<ApiTicket> {
40+
pub async fn get_handler(req: Request<Body>) -> ApiResult<ApiTicketOverview> {
3941
let id = req.param_uuid("id")?;
42+
4043
Span::current().record("id", field::display(id));
4144

4245
let db = req.data::<Database>().unwrap();
43-
let ticket = db.get_ticket(id).await?.ok_or(ApiError::TicketNotFound)?;
4446

45-
let iam = req.iam();
47+
let (ticket, creator, messages) =
48+
db.get_ticket(id).await?.ok_or(ApiError::TicketNotFound)?;
4649

50+
let ticket_audit = db.get_ticket_audit_logs(id).await;
51+
52+
let iam = req.iam();
4753
let current_user = iam.check_current_user_access()?;
48-
if current_user == &ticket.1 || iam.check_admin_access().is_ok() {
49-
Ok(ticket.into())
54+
55+
if current_user == &creator || iam.check_admin_access().is_ok() {
56+
let mut events: Vec<ApiTicketMessageOrAuditLog> = Vec::new();
57+
58+
for message in messages {
59+
events.push(ApiTicketMessageOrAuditLog::Message {
60+
message: message.0,
61+
user: message.1,
62+
});
63+
}
64+
65+
if let Ok(audit_logs) = ticket_audit {
66+
for audit_log in audit_logs {
67+
events.push(ApiTicketMessageOrAuditLog::AuditLog {
68+
audit_log: audit_log.0,
69+
user: audit_log.1,
70+
});
71+
}
72+
}
73+
74+
events.sort_by_key(|event| match event {
75+
ApiTicketMessageOrAuditLog::Message { message, .. } => message.created_at,
76+
ApiTicketMessageOrAuditLog::AuditLog { audit_log, .. } => {
77+
audit_log.created_at
78+
}
79+
});
80+
81+
Ok((ticket, creator, events).into())
5082
} else {
5183
Err(ApiError::TicketNotFound)
5284
}
@@ -206,9 +238,22 @@ mod test {
206238
.call()
207239
.await
208240
.unwrap();
209-
let ticket: ApiTicket = resp.expect_ok().await;
210-
assert_eq!(ticket.messages[0].message, "test");
211-
assert_eq!(ticket.messages[1].message, "test2");
241+
let ticket_overview: super::ApiTicketOverview = resp.expect_ok().await;
242+
243+
let mut message_contents: Vec<String> = Vec::new();
244+
for event in &ticket_overview.events {
245+
if let super::ApiTicketMessageOrAuditLog::Message { message, .. } = event
246+
{
247+
message_contents.push(message.message.clone());
248+
}
249+
}
250+
assert!(
251+
message_contents.len() >= 2,
252+
"Expected at least 2 messages, found {}",
253+
message_contents.len()
254+
);
255+
assert_eq!(message_contents[0], "test");
256+
assert_eq!(message_contents[1], "test2");
212257

213258
let other_user_token = t.user2.token.clone();
214259
let mut resp = t
@@ -228,6 +273,22 @@ mod test {
228273
.call()
229274
.await
230275
.unwrap();
231-
let _ticket: ApiTicket = resp.expect_ok().await;
276+
let staff_ticket_overview: super::ApiTicketOverview =
277+
resp.expect_ok().await;
278+
279+
let mut staff_message_contents: Vec<String> = Vec::new();
280+
for event in &staff_ticket_overview.events {
281+
if let super::ApiTicketMessageOrAuditLog::Message { message, .. } = event
282+
{
283+
staff_message_contents.push(message.message.clone());
284+
}
285+
}
286+
assert!(
287+
staff_message_contents.len() >= 2,
288+
"Expected at least 2 messages for staff view, found {}",
289+
staff_message_contents.len()
290+
);
291+
assert_eq!(staff_message_contents[0], "test");
292+
assert_eq!(staff_message_contents[1], "test2");
232293
}
233294
}

api/src/api/types.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1025,6 +1025,52 @@ pub struct ApiPackageDownloadsRecentVersion {
10251025
pub downloads: Vec<ApiDownloadDataPoint>,
10261026
}
10271027

1028+
#[derive(Debug, Serialize, Deserialize)]
1029+
#[serde(rename_all = "camelCase", tag = "kind")]
1030+
pub enum ApiTicketMessageOrAuditLog {
1031+
Message {
1032+
message: TicketMessage,
1033+
user: UserPublic,
1034+
},
1035+
#[serde(rename_all = "camelCase")]
1036+
AuditLog {
1037+
audit_log: AuditLog,
1038+
user: UserPublic,
1039+
},
1040+
}
1041+
1042+
#[derive(Debug, Serialize, Deserialize)]
1043+
#[serde(rename_all = "camelCase")]
1044+
pub struct ApiTicketOverview {
1045+
pub id: Uuid,
1046+
pub kind: TicketKind,
1047+
pub creator: ApiUser,
1048+
pub meta: serde_json::Value,
1049+
pub closed: bool,
1050+
pub events: Vec<ApiTicketMessageOrAuditLog>,
1051+
pub updated_at: DateTime<Utc>,
1052+
pub created_at: DateTime<Utc>,
1053+
}
1054+
1055+
impl From<(Ticket, User, Vec<ApiTicketMessageOrAuditLog>)>
1056+
for ApiTicketOverview
1057+
{
1058+
fn from(
1059+
(value, user, events): (Ticket, User, Vec<ApiTicketMessageOrAuditLog>),
1060+
) -> Self {
1061+
Self {
1062+
id: value.id,
1063+
kind: value.kind,
1064+
creator: user.into(),
1065+
meta: value.meta,
1066+
closed: value.closed,
1067+
events,
1068+
updated_at: value.updated_at,
1069+
created_at: value.created_at,
1070+
}
1071+
}
1072+
}
1073+
10281074
#[derive(Debug, Serialize, Deserialize)]
10291075
#[serde(rename_all = "camelCase")]
10301076
pub struct ApiTicket {

api/src/db/database.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4564,6 +4564,65 @@ impl Database {
45644564
Ok(Some((ticket, user, messages)))
45654565
}
45664566

4567+
#[instrument(name = "Database::get_ticket_audit_logs", skip(self), err)]
4568+
pub async fn get_ticket_audit_logs(
4569+
&self,
4570+
ticket_id: Uuid,
4571+
) -> Result<Vec<(AuditLog, UserPublic)>> {
4572+
let mut tx = self.pool.begin().await?;
4573+
4574+
let audit_logs = sqlx::query!(
4575+
r#"
4576+
SELECT
4577+
audit_logs.actor_id as "audit_actor_id",
4578+
audit_logs.is_sudo as "audit_is_sudo",
4579+
audit_logs.action as "audit_action",
4580+
audit_logs.meta as "audit_meta",
4581+
audit_logs.created_at as "audit_created_at",
4582+
users.id as "user_id",
4583+
users.name as "user_name",
4584+
users.avatar_url as "user_avatar_url",
4585+
users.github_id as "user_github_id",
4586+
users.updated_at as "user_updated_at",
4587+
users.created_at as "user_created_at"
4588+
FROM
4589+
audit_logs
4590+
LEFT JOIN
4591+
users ON audit_logs.actor_id = users.id
4592+
WHERE
4593+
audit_logs.meta::text LIKE $1
4594+
ORDER BY audit_logs.created_at DESC;
4595+
"#,
4596+
format!("%\"ticket_id\": \"{}\"%", ticket_id),
4597+
)
4598+
.map(|r| {
4599+
let audit_log = AuditLog {
4600+
actor_id: r.audit_actor_id,
4601+
is_sudo: r.audit_is_sudo,
4602+
action: r.audit_action,
4603+
meta: r.audit_meta,
4604+
created_at: r.audit_created_at,
4605+
};
4606+
4607+
let user = UserPublic {
4608+
id: r.user_id,
4609+
name: r.user_name,
4610+
avatar_url: r.user_avatar_url,
4611+
github_id: r.user_github_id,
4612+
updated_at: r.user_updated_at,
4613+
created_at: r.user_created_at,
4614+
};
4615+
4616+
(audit_log, user)
4617+
})
4618+
.fetch_all(&mut *tx)
4619+
.await?;
4620+
4621+
tx.commit().await?;
4622+
4623+
Ok(audit_logs)
4624+
}
4625+
45674626
#[instrument(name = "Database::ticket_add_message", skip(self), err)]
45684627
pub async fn ticket_add_message(
45694628
&self,

api/src/db/models.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ impl FromRow<'_, sqlx::postgres::PgRow> for User {
6060
}
6161
}
6262

63-
#[derive(Debug, Clone)]
63+
#[derive(Debug, Clone, Serialize, Deserialize)]
64+
#[serde(rename_all = "camelCase")]
6465
pub struct UserPublic {
6566
pub id: Uuid,
6667
pub name: String,
@@ -992,7 +993,8 @@ pub struct NewTicketMessage {
992993
pub message: String,
993994
}
994995

995-
#[derive(Debug, Clone)]
996+
#[derive(Debug, Clone, Serialize, Deserialize)]
997+
#[serde(rename_all = "camelCase")]
996998
pub struct TicketMessage {
997999
pub ticket_id: Uuid,
9981000
pub author: Uuid,
@@ -1003,7 +1005,8 @@ pub struct TicketMessage {
10031005

10041006
pub type FullTicket = (Ticket, User, Vec<(TicketMessage, UserPublic)>);
10051007

1006-
#[derive(Debug, Clone)]
1008+
#[derive(Debug, Clone, Serialize, Deserialize)]
1009+
#[serde(rename_all = "camelCase")]
10071010
pub struct AuditLog {
10081011
pub actor_id: Uuid,
10091012
pub is_sudo: bool,

frontend/islands/TicketMessageInput.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,16 @@ import {
55
AdminUpdateTicketRequest,
66
FullUser,
77
NewTicketMessage,
8-
Ticket,
98
} from "../utils/api_types.ts";
109
import { api, path } from "../utils/api.ts";
1110
import { useSignal } from "@preact/signals";
1211

1312
export function TicketMessageInput(
14-
{ ticket, user }: { ticket: Ticket; user: FullUser },
13+
{ ticketId, closed, user }: {
14+
ticketId: string;
15+
closed: boolean;
16+
user: FullUser;
17+
},
1518
) {
1619
const message = useSignal("");
1720
const [error, setError] = useState<string | null>(null);
@@ -38,7 +41,7 @@ export function TicketMessageInput(
3841
}
3942

4043
api.post(
41-
path`/tickets/${ticket.id}`,
44+
path`/tickets/${ticketId}`,
4245
{
4346
message: message.value,
4447
} satisfies NewTicketMessage,
@@ -76,9 +79,9 @@ export function TicketMessageInput(
7679
e.preventDefault();
7780

7881
api.patch(
79-
path`/admin/tickets/${ticket.id}`,
82+
path`/admin/tickets/${ticketId}`,
8083
{
81-
closed: !ticket.closed,
84+
closed: !closed,
8285
} satisfies AdminUpdateTicketRequest,
8386
).then((resp) => {
8487
if (resp.ok) {
@@ -90,7 +93,7 @@ export function TicketMessageInput(
9093
});
9194
}}
9295
>
93-
{ticket.closed
96+
{closed
9497
? (
9598
<>
9699
<TbClock class="text-white" /> Re-open

0 commit comments

Comments
 (0)