Compare commits

...

10 commits

Author SHA1 Message Date
732566a29c Use the slim base for the Docker image
All checks were successful
/ build (push) Successful in 1m35s
/ deploy (push) Has been skipped
This saves about 85% of the resulting image size.
2025-09-07 15:01:51 +02:00
5898a46a1b Add UI to edit and display event notices
Add a warning like display of event notices to the event card and the
event slot card and indicate in the timesheet that an event has a
notice.  Also includes the input controls needed to edit the notice.
2025-09-06 23:54:42 +02:00
adeef4f629 Transform the account field in sessions
When serving sessions instead of passing the ServerUser directly,
convert it to the ApiAccount format.
2025-09-06 23:53:54 +02:00
37edf122a1 Support multiline descriptions for entities
Use a textarea for editing the description and preserve linebreaks
when it's displayed in the UI using a new preWrap class for this
purpose.
2025-09-06 23:53:54 +02:00
96681bfd37 Strike through the removed part of diff entries
Communicate better that the part marked with - is the removed part by
striking out the text.
2025-09-06 23:53:54 +02:00
6d9d937c70 Render multi-line diff entries
Rework the rendering of the DiffEntry component to properly show
multiline entries as spanning multiple lines.
2025-09-06 23:53:53 +02:00
a8c62e6688 Add missing event host field to new events
Add field to input the host of the event when adding a new event to the
table of events.  This also fixes field order in the table being broken.
2025-09-06 16:24:56 +02:00
f29b1f7afd Add notice text field to events
Add a general text field for communicating extra information that
readers of the schedule should pay special attention to, for example to
highight a change made to the event.
2025-09-06 16:20:27 +02:00
9a46ea5af0 Add cancelled field to event slots
Make it possible to represent one slot out of a multi-slot event being
cancelled by adding a field for it in the slot, in addition to the
existing field on the event itself.
2025-09-06 15:54:58 +02:00
d006be251c Create a per-user admin page to inspect users
Add page to allow admins to inspect all of the details stored on the
server of a user account.  For now this is just the UserDetails, but
in the future this is planned to be expanded to also show sessions
and logs.
2025-09-06 15:16:02 +02:00
23 changed files with 363 additions and 80 deletions

View file

@ -2,7 +2,7 @@
# SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no> # SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
# SPDX-License-Identifier: AGPL-3.0-or-later # SPDX-License-Identifier: AGPL-3.0-or-later
# Based on Next.js's docker image example # Based on Next.js's docker image example
FROM node:22 AS base FROM node:22-slim AS base
# Install dependencies only when needed # Install dependencies only when needed
FROM base AS deps FROM base AS deps

View file

@ -83,3 +83,7 @@ label>* {
label + label { label + label {
margin-block-start: 0.5rem; margin-block-start: 0.5rem;
} }
.preWrap {
white-space: pre-wrap;
}

View file

@ -8,7 +8,15 @@
<p v-if=event.host> <p v-if=event.host>
Host: {{ event.host }} Host: {{ event.host }}
</p> </p>
<p>{{ event.description ?? "No description provided" }}</p> <div v-if="event.notice" class="notice preWrap">
<div class="noticeIcon">
</div>
<p>
{{ event.notice }}
</p>
</div>
<p class="preWrap">{{ event.description ?? "No description provided" }}</p>
<p v-if="event.interested"> <p v-if="event.interested">
{{ event.interested }} interested {{ event.interested }} interested
</p> </p>
@ -79,6 +87,22 @@ async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
margin-block-start: 0.5rem; margin-block-start: 0.5rem;
} }
.notice {
display: flex;
width: fit-content;
gap: 0.5rem;
padding: 0.5rem;
margin-block: 0.5rem;
border-radius: 0.25rem;
border: 1px solid color-mix(in oklab, CanvasText, orange 50%);
background-color: color-mix(in oklab, Canvas, orange 40%);
}
.noticeIcon {
flex: 0 0 auto;
align-self: center;
font-size: 1rem;
}
button { button {
padding-inline: 0.2em; padding-inline: 0.2em;
} }

View file

@ -13,7 +13,15 @@
<p v-if=event?.host> <p v-if=event?.host>
Host: {{ event.host }} Host: {{ event.host }}
</p> </p>
<p>{{ event?.description ?? "No description provided" }}</p> <div v-if="event?.notice" class="notice preWrap">
<div class="noticeIcon">
</div>
<p>
{{ event.notice }}
</p>
</div>
<p class="preWrap">{{ event?.description ?? "No description provided" }}</p>
<p v-if="locations.length"> <p v-if="locations.length">
At {{ locations.map(location => location?.name ?? "unknown").join(" + ") }} At {{ locations.map(location => location?.name ?? "unknown").join(" + ") }}
</p> </p>
@ -62,6 +70,22 @@ function formatTime(time: DateTime) {
margin-block-start: 0.5rem; margin-block-start: 0.5rem;
} }
.notice {
display: flex;
width: fit-content;
gap: 0.5rem;
padding: 0.5rem;
margin-block: 0.5rem;
border-radius: 0.25rem;
border: 1px solid color-mix(in oklab, CanvasText, orange 50%);
background-color: color-mix(in oklab, Canvas, orange 40%);
}
.noticeIcon {
flex: 0 0 auto;
align-self: center;
font-size: 1rem;
}
button { button {
padding-inline: 0.2em; padding-inline: 0.2em;
} }

View file

@ -5,7 +5,7 @@
<template> <template>
<section class="shift"> <section class="shift">
<h3>{{ shift.name }}</h3> <h3>{{ shift.name }}</h3>
<p>{{ shift.description ?? "No description provided" }}</p> <p class="preWrap">{{ shift.description ?? "No description provided" }}</p>
<h4>Timeslots</h4> <h4>Timeslots</h4>
<ul> <ul>

View file

@ -15,9 +15,14 @@
:key="index" :key="index"
:class="type" :class="type"
> >
<div class="symbol">
{{ { "removed": "- ", "added": "+ "}[type] }}
</div>
<div class="content">
{{ text }} {{ text }}
</div> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@ -33,20 +38,23 @@ defineProps<{
grid-template-columns: 5rem 1fr; grid-template-columns: 5rem 1fr;
column-gap: 1rem; column-gap: 1rem;
} }
.removed { :is(.removed, .added) {
display: flex;
grid-column: 2 / 2; grid-column: 2 / 2;
white-space: pre-wrap;
}
:is(.removed, .added) .symbol {
display: block;
font-family: monospace;
flex: 0 0 auto;
}
.removed {
color: color-mix(in srgb, CanvasText, red 40%); color: color-mix(in srgb, CanvasText, red 40%);
} }
.removed::before { .removed .content{
content: "- "; text-decoration-line: line-through;
font-family: monospace;
} }
.added { .added {
grid-column: 2 / 2;
color: color-mix(in srgb, CanvasText, green 40%); color: color-mix(in srgb, CanvasText, green 40%);
} }
.added::before {
content: "+ ";
font-family: monospace;
}
</style> </style>

View file

@ -318,6 +318,7 @@ function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
start, start,
end, end,
new Set(newEventLocationIds.value), new Set(newEventLocationIds.value),
false,
new Set(), new Set(),
0, 0,
); );

View file

@ -10,6 +10,7 @@
<th>id</th> <th>id</th>
<th>name</th> <th>name</th>
<th>host</th> <th>host</th>
<th>notice</th>
<th>description</th> <th>description</th>
<th>p</th> <th>p</th>
<th>s</th> <th>s</th>
@ -39,11 +40,18 @@
> >
</td> </td>
<td> <td>
<input <textarea
type="text" rows="1"
:disabled="!canEdit(event)"
v-model="event.notice"
/>
</td>
<td>
<textarea
rows="1"
:disabled="!canEdit(event)" :disabled="!canEdit(event)"
v-model="event.description" v-model="event.description"
> />
</td> </td>
<td> <td>
<input <input
@ -78,9 +86,21 @@
<td> <td>
<input <input
type="text" type="text"
v-model="newEventDescription" v-model="newEventHost"
> >
</td> </td>
<td>
<textarea
rows="1"
v-model="newEventNotice"
/>
</td>
<td>
<textarea
rows="1"
v-model="newEventDescription"
/>
</td>
<td> <td>
<input <input
type="checkbox" type="checkbox"
@ -110,7 +130,8 @@
<td>{{ event.id }}</td> <td>{{ event.id }}</td>
<td>{{ event.name }}</td> <td>{{ event.name }}</td>
<td>{{ event.host }}</td> <td>{{ event.host }}</td>
<td>{{ event.description }}</td> <td class="preWrap">{{ event.notice }}</td>
<td class="preWrap">{{ event.description }}</td>
<td>{{ event.crew ? "" : "Yes"}}</td> <td>{{ event.crew ? "" : "Yes"}}</td>
<td>{{ event.slots.size ? event.slots.size : "" }}</td> <td>{{ event.slots.size ? event.slots.size : "" }}</td>
</tr> </tr>
@ -136,6 +157,8 @@ function canEdit(event: ClientScheduleEvent) {
} }
const newEventName = ref(""); const newEventName = ref("");
const newEventHost = ref("");
const newEventNotice = ref("");
const newEventDescription = ref(""); const newEventDescription = ref("");
const newEventPublic = ref(false); const newEventPublic = ref(false);
function eventExists(name: string) { function eventExists(name: string) {
@ -156,8 +179,9 @@ function newEvent() {
schedule.value.nextClientId--, schedule.value.nextClientId--,
newEventName.value, newEventName.value,
!newEventPublic.value, !newEventPublic.value,
"", newEventHost.value,
false, false,
newEventNotice.value,
newEventDescription.value, newEventDescription.value,
0, 0,
new Set(), new Set(),
@ -165,6 +189,8 @@ function newEvent() {
); );
schedule.value.events.add(event); schedule.value.events.add(event);
newEventName.value = ""; newEventName.value = "";
newEventHost.value = "";
newEventNotice.value = "";
newEventDescription.value = ""; newEventDescription.value = "";
newEventPublic.value = false; newEventPublic.value = false;
} }

View file

@ -28,10 +28,10 @@
> >
</td> </td>
<td> <td>
<input <textarea
type="text" rows="1"
v-model="location.description" v-model="location.description"
> />
</td> </td>
<td> <td>
<button <button
@ -49,7 +49,7 @@
<template v-else> <template v-else>
<td>{{ location.id }}</td> <td>{{ location.id }}</td>
<td>{{ location.name }}</td> <td>{{ location.name }}</td>
<td>{{ location.description }}</td> <td class="preWrap">{{ location.description }}</td>
</template> </template>
</tr> </tr>
<tr v-if='edit'> <tr v-if='edit'>
@ -63,10 +63,10 @@
> >
</td> </td>
<td> <td>
<input <textarea
type="text" rows="1"
v-model="newLocationDescription" v-model="newLocationDescription"
> />
</td> </td>
<td colspan="1"> <td colspan="1">
<button <button

View file

@ -28,10 +28,10 @@
> >
</td> </td>
<td> <td>
<input <textarea
type="text" rows="1"
v-model="role.description" v-model="role.description"
> />
</td> </td>
<td> <td>
<button <button
@ -55,10 +55,10 @@
> >
</td> </td>
<td> <td>
<input <textarea
type="text" rows="1"
v-model="newRoleDescription" v-model="newRoleDescription"
> />
</td> </td>
<td> <td>
<button <button
@ -80,7 +80,7 @@
> >
<td>{{ role.id }}</td> <td>{{ role.id }}</td>
<td>{{ role.name }}</td> <td>{{ role.name }}</td>
<td>{{ role.description }}</td> <td class="preWrap">{{ role.description }}</td>
</tr> </tr>
</template> </template>
</tbody> </tbody>

View file

@ -37,10 +37,10 @@
</td> </td>
<td>{{ shift.slots.size ? shift.slots.size : "" }}</td> <td>{{ shift.slots.size ? shift.slots.size : "" }}</td>
<td> <td>
<input <textarea
type="text" rows="1"
v-model="shift.description" v-model="shift.description"
> />
</td> </td>
<td> <td>
<button <button
@ -71,10 +71,10 @@
</td> </td>
<td></td> <td></td>
<td> <td>
<input <textarea
type="text" rows="1"
v-model="newShiftDescription" v-model="newShiftDescription"
> />
</td> </td>
<td> <td>
<button <button
@ -98,7 +98,7 @@
<td>{{ shift.name }}</td> <td>{{ shift.name }}</td>
<td>{{ shift.roleId }}</td> <td>{{ shift.roleId }}</td>
<td>{{ shift.slots.size ? shift.slots.size : "" }}</td> <td>{{ shift.slots.size ? shift.slots.size : "" }}</td>
<td>{{ shift.description }}</td> <td class="preWrap">{{ shift.description }}</td>
</tr> </tr>
</template> </template>
</tbody> </tbody>

View file

@ -17,7 +17,12 @@
<tr v-for="user in usersStore.users.values()"> <tr v-for="user in usersStore.users.values()">
<td>{{ user.id }}</td> <td>{{ user.id }}</td>
<td> <td>
<NuxtLink :to="`/admin/users/${user.id}`">
<template v-if="user.name">
{{ user.name }} {{ user.name }}
</template>
<i v-else>(empty)</i>
</NuxtLink>
</td> </td>
<td> <td>
<select <select
@ -39,7 +44,7 @@
<button <button
v-if="user.isModified()" v-if="user.isModified()"
type="button" type="button"
@click="saveUser(user);" @click="usersStore.saveUser(user);"
>Save</button> >Save</button>
<button <button
v-if="user.isModified()" v-if="user.isModified()"
@ -55,18 +60,6 @@
<script lang="ts" setup> <script lang="ts" setup>
useEventSource(); useEventSource();
const usersStore = useUsersStore(); const usersStore = useUsersStore();
async function saveUser(user: ClientUser) {
try {
await $fetch("/api/admin/user", {
method: "PATCH",
body: user.toApi(),
});
} catch (err: any) {
console.error(err);
alert(err?.data?.message ?? err.message);
}
}
</script> </script>
<style> <style>

View file

@ -105,6 +105,7 @@
:class='{"event": cell.slot, "crew": cell.event?.crew }' :class='{"event": cell.slot, "crew": cell.event?.crew }'
:title="cell.event?.name" :title="cell.event?.name"
> >
{{ cell.event?.notice ? "⚠️" : undefined }}
{{ cell.event?.name }} {{ cell.event?.name }}
</td> </td>
</tr> </tr>

View file

@ -0,0 +1,94 @@
<template>
<main v-if="userDetails.deleted">
<h1>Deleted user {{ id }}</h1>
</main>
<main v-else>
<h1>User {{ user.name }}</h1>
<dl>
<dt>
<label for="user-type">
Type
</label>
</dt>
<dd>
<select
v-if='user.type !== "anonymous"'
v-model="user.type"
>
<option value="regular">Regular</option>
<option value="crew">Crew</option>
<option value="admin">Admin</option>
</select>
<template v-else>
{{ user.type }}
</template>
</dd>
<dt>Interested Events:</dt>
<dd>{{ userDetails.interestedEventIds }}</dd>
<dt>Interested Slots:</dt>
<dd>{{ userDetails.interestedEventSlotIds }}</dd>
<dt>Timezone:</dt>
<dd>{{ userDetails.timezone }}</dd>
<dt>Locale:</dt>
<dd>{{ userDetails.locale }}</dd>
</dl>
<button
:disabled="!user.isModified()"
type="button"
@click="usersStore.saveUser(user);"
>Save</button>
<button
:disabled="!user.isModified()"
type="button"
@click="user.discard()"
>Discard</button>
</main>
</template>
<script lang="ts" setup>
import type { ApiTombstone, ApiUserDetails } from '~/shared/types/api';
useHead({
title: "Admin",
});
useEventSource();
const route = useRoute();
const usersStore = useUsersStore();
await usersStore.fetch();
const id = computed(() => {
const id = queryToNumber(route.params.id);
if (id === undefined) {
throw createError({
statusCode: 400,
statusMessage: "Bad Request",
message: "User id required",
});
}
return id;
});
const user = computed(() => {
const user = usersStore.users.get(id.value);
if (user === undefined) {
throw createError({
statusCode: 404,
statusMessage: "Not Found",
message: "User not found",
});
}
return user;
});
const { pending, data, error } = await useFetch(() => `/api/users/${id.value}/details`);
const userDetails = data as Ref<ApiUserDetails | ApiTombstone>;
</script>
<style>
dl {
display: grid;
grid-template-columns: auto 1fr;
column-gap: 0.5rem;
}
</style>

View file

@ -0,0 +1,34 @@
import { z } from "zod/v4-mini";
import { readUsers } from "~/server/database";
import { serverUserToApiDetails } from "~/server/utils/user";
const integerStringSchema = z.pipe(
z.string().check(z.regex(/^\d+/)),
z.transform(Number)
);
const detailsSchema = z.object({
id: integerStringSchema,
});
export default defineEventHandler(async (event) => {
await requireServerSessionWithAdmin(event);
const users = await readUsers();
const { success, error, data: params } = detailsSchema.safeParse(getRouterParams(event));
if (!success) {
throw createError({
status: 400,
statusText: "Bad Request",
message: z.prettifyError(error),
});
}
const user = users.find(user => user.id === params.id);
if (!user) {
throw createError({
statusCode: 404,
statusMessage: "Not found",
});
}
return serverUserToApiDetails(user);
})

View file

@ -9,7 +9,7 @@ const locations = [
{ {
id: 1, id: 1,
name: "Stage", name: "Stage",
description: "Inside the main building.", description: "Inside the main building.\n\nMind the gap.",
updatedAt: "d-1 18:21", updatedAt: "d-1 18:21",
}, },
{ {
@ -43,7 +43,8 @@ let eventId = 1;
const events = [ const events = [
{ {
name: "Arcade", name: "Arcade",
description: "Play retro games!", notice: "No food or drinks allowed!\n\nClosed drinking containers are okay.",
description: "Play retro games!\n\nWe have anything from C64 to PS5.",
slots: [ slots: [
"d1 12:00 4h clubhouse", "d1 12:00 4h clubhouse",
"d2 12:00 4h clubhouse", "d2 12:00 4h clubhouse",
@ -158,13 +159,14 @@ const events = [
const idMedic = 1; const idMedic = 1;
const idSecurity = 2; const idSecurity = 2;
const roles = [ const roles = [
{ id: idMedic, name: "Medic", updatedAt: "d-2 12:34" }, { id: idMedic, name: "Medic", description: "Helping those in need.\n\nAsk lead if in doubt.", updatedAt: "d-2 12:34" },
{ id: idSecurity, name: "Security", updatedAt: "d-2 12:39" }, { id: idSecurity, name: "Security", description: "Keeping the con safe", updatedAt: "d-2 12:39" },
] ]
const shifts = [ const shifts = [
{ {
name: "Medic Early", name: "Medic Early",
description: "Not much happens early.\n\nPrefer strolling outside to be visible.",
roleId: idMedic, roleId: idMedic,
slots: [ slots: [
"d1 12:00 4h", "d1 12:00 4h",
@ -338,10 +340,11 @@ export function generateDemoSchedule(): ApiSchedule {
id: 111, id: 111,
updatedAt: toIso(toDate(origin, "d-2", "10:01")), updatedAt: toIso(toDate(origin, "d-2", "10:01")),
events: events.map( events: events.map(
({ id, name, crew, description, slots }) => ({ ({ id, name, crew, notice, description, slots }) => ({
id, id,
name, name,
crew, crew,
notice,
description, description,
interested: eventCounts.get(id), interested: eventCounts.get(id),
slots: slots.map(shorthand => toSlot(origin, shorthand, slotCounts, eventSlotIdToAssigned)), slots: slots.map(shorthand => toSlot(origin, shorthand, slotCounts, eventSlotIdToAssigned)),
@ -357,16 +360,18 @@ export function generateDemoSchedule(): ApiSchedule {
}) })
), ),
roles: roles.map( roles: roles.map(
({ id, name, updatedAt }) => ({ ({ id, name, description, updatedAt }) => ({
id, id,
name, name,
description,
updatedAt: toIso(toDate(origin, ...(updatedAt.split(" ")) as [string, string])), updatedAt: toIso(toDate(origin, ...(updatedAt.split(" ")) as [string, string])),
}) })
), ),
shifts: shifts.map( shifts: shifts.map(
({ id, name, roleId, slots }) => ({ ({ id, name, description, roleId, slots }) => ({
id, id,
name, name,
description,
roleId, roleId,
slots: slots.map(shorthand => toShift(origin, shorthand, shiftSlotIdToAssigned)), slots: slots.map(shorthand => toShift(origin, shorthand, shiftSlotIdToAssigned)),
updatedAt: toIso(toDate(origin, "d-1", "13:23")), updatedAt: toIso(toDate(origin, "d-1", "13:23")),

View file

@ -15,6 +15,7 @@ import {
} from "~/server/database"; } from "~/server/database";
import { broadcastEvent } from "../streams"; import { broadcastEvent } from "../streams";
import type { ApiAuthenticationProvider, ApiSession } from "~/shared/types/api"; import type { ApiAuthenticationProvider, ApiSession } from "~/shared/types/api";
import { serverUserToApiAccount } from "./user";
async function removeSessionSubscription(sessionId: number) { async function removeSessionSubscription(sessionId: number) {
const subscriptions = await readSubscriptions(); const subscriptions = await readSubscriptions();
@ -182,7 +183,7 @@ export async function serverSessionToApi(event: H3Event, session: ServerSession)
return { return {
id: session.id, id: session.id,
account, account: serverUserToApiAccount(account),
authenticationProvider: session.authenticationProvider, authenticationProvider: session.authenticationProvider,
authenticationName: session.authenticationName, authenticationName: session.authenticationName,
push, push,

View file

@ -3,7 +3,7 @@
SPDX-License-Identifier: AGPL-3.0-or-later SPDX-License-Identifier: AGPL-3.0-or-later
*/ */
import type { ServerUser } from "~/server/database" import type { ServerUser } from "~/server/database"
import type { ApiTombstone, ApiUser } from "~/shared/types/api"; import type { ApiAccount, ApiTombstone, ApiUser, ApiUserDetails } from "~/shared/types/api";
export function serverUserToApi(user: ServerUser): ApiUser | ApiTombstone { export function serverUserToApi(user: ServerUser): ApiUser | ApiTombstone {
if (user.deleted) { if (user.deleted) {
@ -20,3 +20,37 @@ export function serverUserToApi(user: ServerUser): ApiUser | ApiTombstone {
name: user.name, name: user.name,
} }
} }
export function serverUserToApiDetails(user: ServerUser): ApiUserDetails | ApiTombstone {
if (user.deleted) {
return {
id: user.id,
updatedAt: user.updatedAt,
deleted: true,
}
}
return {
id: user.id,
updatedAt: user.updatedAt,
interestedEventIds: user.interestedEventIds,
interestedEventSlotIds: user.interestedEventSlotIds,
timezone: user.timezone,
locale: user.locale,
}
}
export function serverUserToApiAccount(user?: ServerUser): ApiAccount | undefined {
if (!user || user.deleted) {
return undefined;
}
return {
id: user.id,
updatedAt: user.updatedAt,
type: user.type,
name: user.name,
interestedEventIds: user.interestedEventIds,
interestedEventSlotIds: user.interestedEventSlotIds,
timezone: user.timezone,
locale: user.locale,
}
}

View file

@ -35,17 +35,7 @@ export const apiUserTypeSchema = z.union([
]) ])
export type ApiUserType = z.infer<typeof apiUserTypeSchema>; export type ApiUserType = z.infer<typeof apiUserTypeSchema>;
export interface ApiAccount { export type ApiAccount = ApiUser & ApiUserDetails
id: Id,
updatedAt: string,
type: ApiUserType,
/** Name of the account. Not present on anonymous accounts */
name?: string,
interestedEventIds?: number[],
interestedEventSlotIds?: number[],
timezone?: string,
locale?: string,
}
export const apiAccountPatchSchema = z.object({ export const apiAccountPatchSchema = z.object({
name: z.optional(z.string()), name: z.optional(z.string()),
@ -91,6 +81,7 @@ export const apiScheduleEventSlotSchema = z.object({
start: z.string(), start: z.string(),
end: z.string(), end: z.string(),
locationIds: z.array(idSchema), locationIds: z.array(idSchema),
cancelled: z.optional(z.boolean()),
assigned: z.optional(z.array(z.number())), assigned: z.optional(z.array(z.number())),
interested: z.optional(z.number()), interested: z.optional(z.number()),
}); });
@ -101,6 +92,7 @@ export const apiScheduleEventSchema = defineApiEntity({
crew: z.optional(z.boolean()), crew: z.optional(z.boolean()),
host: z.optional(z.string()), host: z.optional(z.string()),
cancelled: z.optional(z.boolean()), cancelled: z.optional(z.boolean()),
notice: z.optional(z.string()),
description: z.optional(z.string()), description: z.optional(z.string()),
interested: z.optional(z.number()), interested: z.optional(z.number()),
slots: z.array(apiScheduleEventSlotSchema), slots: z.array(apiScheduleEventSlotSchema),
@ -151,6 +143,16 @@ export const apiUserPatchSchema = z.object({
}); });
export type ApiUserPatch = z.infer<typeof apiUserPatchSchema>; export type ApiUserPatch = z.infer<typeof apiUserPatchSchema>;
export interface ApiUserDetails {
id: Id,
updatedAt: string,
deleted?: false,
interestedEventIds?: number[],
interestedEventSlotIds?: number[],
timezone?: string,
locale?: string,
}
export interface ApiAccountUpdate { export interface ApiAccountUpdate {
type: "account-update", type: "account-update",
data: ApiAccount, data: ApiAccount,

View file

@ -62,6 +62,17 @@ export const useUsersStore = defineStore("users", () => {
state.fetched.value = false; state.fetched.value = false;
await actions.fetch(); await actions.fetch();
}, },
async saveUser(user: ClientUser) {
try {
await $fetch("/api/admin/user", {
method: "PATCH",
body: user.toApi(),
});
} catch (err: any) {
console.error(err);
alert(err?.data?.message ?? err.message);
}
},
} }
appEventSource?.addEventListener("update", (event) => { appEventSource?.addEventListener("update", (event) => {

View file

@ -21,15 +21,15 @@ function fixtureClientSchedule(multiSlot = false) {
const events = [ const events = [
new ClientScheduleEvent( new ClientScheduleEvent(
1, now, false, "Up", false, "", false, "What's Up?", 0, new Set(multiSlot ? [1, 2] : [1]), 1, now, false, "Up", false, "", false, "", "What's Up?", 0, new Set(multiSlot ? [1, 2] : [1]),
), ),
new ClientScheduleEvent( new ClientScheduleEvent(
2, now, false, "Down", false, "", false, "", 0, new Set(multiSlot ? [] : [2]), 2, now, false, "Down", false, "", false, "", "", 0, new Set(multiSlot ? [] : [2]),
), ),
]; ];
const eventSlots = idMap([ const eventSlots = idMap([
new ClientScheduleEventSlot(1, false, false, 1, now, now.plus({ hours: 1 }), new Set([left.id]), new Set(), 0), new ClientScheduleEventSlot(1, false, false, 1, now, now.plus({ hours: 1 }), new Set([left.id]), false, new Set(), 0),
new ClientScheduleEventSlot(2, false, false, multiSlot ? 1 : 2, now, now.plus({ hours: 2 }), new Set([right.id]), new Set(), 0), new ClientScheduleEventSlot(2, false, false, multiSlot ? 1 : 2, now, now.plus({ hours: 2 }), new Set([right.id]), false, new Set(), 0),
]); ]);
const red = new ClientScheduleRole(1, now, false, "Red", "Is a color."); const red = new ClientScheduleRole(1, now, false, "Red", "Is a color.");
@ -174,7 +174,7 @@ describe("class ClientSchedule", () => {
], ],
[ [
"event", "event",
(schedule) => ClientScheduleEvent.create(schedule, 3, "New location", false, "", false, "", 0, new Set(), { zone, locale }) (schedule) => ClientScheduleEvent.create(schedule, 3, "New location", false, "", false, "", "", 0, new Set(), { zone, locale })
], ],
[ [
"role", "role",
@ -277,6 +277,7 @@ describe("class ClientSchedule", () => {
now, now,
now.plus({ hours: 3 }), now.plus({ hours: 3 }),
new Set(), new Set(),
false,
new Set(), new Set(),
0, 0,
); );

View file

@ -136,6 +136,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
serverCrew: boolean; serverCrew: boolean;
serverHost: string; serverHost: string;
serverCancelled: boolean; serverCancelled: boolean;
serverNotice: string;
serverDescription: string; serverDescription: string;
serverInterested: number; serverInterested: number;
serverSlotIds: Set<Id>; serverSlotIds: Set<Id>;
@ -148,6 +149,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
public crew: boolean, public crew: boolean,
public host: string, public host: string,
public cancelled: boolean, public cancelled: boolean,
public notice: string,
public description: string, public description: string,
public interested: number, public interested: number,
public slotIds: Set<Id>, public slotIds: Set<Id>,
@ -157,6 +159,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
this.serverCrew = crew; this.serverCrew = crew;
this.serverHost = host; this.serverHost = host;
this.serverCancelled = cancelled; this.serverCancelled = cancelled;
this.serverNotice = notice;
this.serverDescription = description; this.serverDescription = description;
this.serverInterested = interested; this.serverInterested = interested;
this.serverSlotIds = new Set(slotIds); this.serverSlotIds = new Set(slotIds);
@ -173,6 +176,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
|| this.crew !== this.serverCrew || this.crew !== this.serverCrew
|| this.host !== this.serverHost || this.host !== this.serverHost
|| this.cancelled !== this.serverCancelled || this.cancelled !== this.serverCancelled
|| this.notice !== this.serverNotice
|| this.description !== this.serverDescription || this.description !== this.serverDescription
|| this.interested !== this.serverInterested || this.interested !== this.serverInterested
|| !setEquals(this.slotIds, this.serverSlotIds) || !setEquals(this.slotIds, this.serverSlotIds)
@ -190,6 +194,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
this.crew = this.serverCrew; this.crew = this.serverCrew;
this.host = this.serverHost; this.host = this.serverHost;
this.cancelled = this.serverCancelled; this.cancelled = this.serverCancelled;
this.notice = this.serverNotice;
this.description = this.serverDescription; this.description = this.serverDescription;
this.interested = this.serverInterested; this.interested = this.serverInterested;
for (const id of this.serverSlotIds) { for (const id of this.serverSlotIds) {
@ -208,6 +213,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
crew: boolean, crew: boolean,
host: string, host: string,
cancelled: boolean, cancelled: boolean,
notice: string,
description: string, description: string,
interested: number, interested: number,
slotIds: Set<Id>, slotIds: Set<Id>,
@ -221,6 +227,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
crew, crew,
host, host,
cancelled, cancelled,
notice,
description, description,
interested, interested,
slotIds, slotIds,
@ -241,6 +248,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
api.crew ?? false, api.crew ?? false,
api.host ?? "", api.host ?? "",
api.cancelled ?? false, api.cancelled ?? false,
api.notice ?? "",
api.description ?? "", api.description ?? "",
api.interested ?? 0, api.interested ?? 0,
new Set(api.slots.map(slot => slot.id)), new Set(api.slots.map(slot => slot.id)),
@ -258,6 +266,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
this.serverCrew = api.crew ?? false; this.serverCrew = api.crew ?? false;
this.serverHost = api.host ?? ""; this.serverHost = api.host ?? "";
this.serverCancelled = api.cancelled ?? false; this.serverCancelled = api.cancelled ?? false;
this.serverNotice = api.notice ?? "";
this.serverDescription = api.description ?? ""; this.serverDescription = api.description ?? "";
this.serverInterested = api.interested ?? 0; this.serverInterested = api.interested ?? 0;
this.serverSlotIds = new Set(api.slots.map(slot => slot.id)); this.serverSlotIds = new Set(api.slots.map(slot => slot.id));
@ -281,6 +290,7 @@ export class ClientScheduleEvent extends ClientEntity<ApiScheduleEvent> {
crew: this.crew || undefined, crew: this.crew || undefined,
host: this.host || undefined, host: this.host || undefined,
cancelled: this.cancelled || undefined, cancelled: this.cancelled || undefined,
notice: this.notice || undefined,
description: this.description || undefined, description: this.description || undefined,
interested: this.interested || undefined, interested: this.interested || undefined,
slots: [...this.slots.values()].filter(slot => !slot.deleted).map(slot => slot.toApi()), slots: [...this.slots.values()].filter(slot => !slot.deleted).map(slot => slot.toApi()),
@ -295,6 +305,7 @@ export class ClientScheduleEventSlot {
serverStart: DateTime; serverStart: DateTime;
serverEnd: DateTime; serverEnd: DateTime;
serverLocationIds: Set<Id>; serverLocationIds: Set<Id>;
serverCancelled: boolean;
serverAssigned: Set<Id>; serverAssigned: Set<Id>;
serverInterested: number; serverInterested: number;
@ -306,6 +317,7 @@ export class ClientScheduleEventSlot {
public start: DateTime, public start: DateTime,
public end: DateTime, public end: DateTime,
public locationIds: Set<Id>, public locationIds: Set<Id>,
public cancelled: boolean,
public assigned: Set<Id>, public assigned: Set<Id>,
public interested: number, public interested: number,
) { ) {
@ -314,6 +326,7 @@ export class ClientScheduleEventSlot {
this.serverStart = start; this.serverStart = start;
this.serverEnd = end; this.serverEnd = end;
this.serverLocationIds = new Set(locationIds); this.serverLocationIds = new Set(locationIds);
this.serverCancelled = cancelled;
this.serverAssigned = new Set(assigned); this.serverAssigned = new Set(assigned);
this.serverInterested = interested; this.serverInterested = interested;
} }
@ -327,6 +340,7 @@ export class ClientScheduleEventSlot {
|| this.start.toMillis() !== this.serverStart.toMillis() || this.start.toMillis() !== this.serverStart.toMillis()
|| this.end.toMillis() !== this.serverEnd.toMillis() || this.end.toMillis() !== this.serverEnd.toMillis()
|| !setEquals(this.locationIds, this.serverLocationIds) || !setEquals(this.locationIds, this.serverLocationIds)
|| this.cancelled !== this.serverCancelled
|| !setEquals(this.assigned, this.serverAssigned) || !setEquals(this.assigned, this.serverAssigned)
|| this.interested !== this.serverInterested || this.interested !== this.serverInterested
); );
@ -349,6 +363,7 @@ export class ClientScheduleEventSlot {
this.start = this.serverStart; this.start = this.serverStart;
this.end = this.serverEnd; this.end = this.serverEnd;
this.locationIds = new Set(this.serverLocationIds); this.locationIds = new Set(this.serverLocationIds);
this.cancelled = this.serverCancelled;
this.assigned = new Set(this.serverAssigned); this.assigned = new Set(this.serverAssigned);
this.interested = this.serverInterested; this.interested = this.serverInterested;
} }
@ -360,6 +375,7 @@ export class ClientScheduleEventSlot {
start: DateTime, start: DateTime,
end: DateTime, end: DateTime,
locationIds: Set<Id>, locationIds: Set<Id>,
cancelled: boolean,
assigned: Set<Id>, assigned: Set<Id>,
interested: number, interested: number,
) { ) {
@ -371,6 +387,7 @@ export class ClientScheduleEventSlot {
start, start,
end, end,
locationIds, locationIds,
cancelled,
assigned, assigned,
interested, interested,
); );
@ -391,6 +408,7 @@ export class ClientScheduleEventSlot {
DateTime.fromISO(api.start, opts), DateTime.fromISO(api.start, opts),
DateTime.fromISO(api.end, opts), DateTime.fromISO(api.end, opts),
new Set(api.locationIds), new Set(api.locationIds),
api.cancelled ?? false,
new Set(api.assigned), new Set(api.assigned),
api.interested ?? 0, api.interested ?? 0,
); );
@ -408,6 +426,7 @@ export class ClientScheduleEventSlot {
this.serverStart = DateTime.fromISO(api.start, opts); this.serverStart = DateTime.fromISO(api.start, opts);
this.serverEnd = DateTime.fromISO(api.end, opts); this.serverEnd = DateTime.fromISO(api.end, opts);
this.serverLocationIds = new Set(api.locationIds); this.serverLocationIds = new Set(api.locationIds);
this.serverCancelled = api.cancelled ?? false;
this.serverAssigned = new Set(api.assigned); this.serverAssigned = new Set(api.assigned);
this.serverInterested = api.interested ?? 0; this.serverInterested = api.interested ?? 0;
if (!wasModified || !this.isModified()) { if (!wasModified || !this.isModified()) {
@ -424,6 +443,7 @@ export class ClientScheduleEventSlot {
start: toIso(this.start), start: toIso(this.start),
end: toIso(this.end), end: toIso(this.end),
locationIds: [...this.locationIds], locationIds: [...this.locationIds],
cancelled: this.cancelled || undefined,
assigned: this.assigned.size ? [...this.assigned] : undefined, assigned: this.assigned.size ? [...this.assigned] : undefined,
interested: this.interested || undefined, interested: this.interested || undefined,
} }