From 56791609f47fe0df901438a4834fe2471530c330 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 12 Sep 2025 19:23:34 +0200 Subject: [PATCH 01/13] Add override for the event name in timetable Add timetableName field to events that override which name is shown in the timetable in order to allow using a custom condensed title in the timetable for short events. --- components/DiffScheduleEvent.vue | 6 ++++++ components/TableScheduleEvents.vue | 18 ++++++++++++++++++ components/Timetable.vue | 2 +- shared/types/api.ts | 1 + utils/client-schedule.nuxt.test.ts | 4 ++-- utils/client-schedule.ts | 10 ++++++++++ 6 files changed, 38 insertions(+), 3 deletions(-) diff --git a/components/DiffScheduleEvent.vue b/components/DiffScheduleEvent.vue index 57fc857..ee30c2e 100644 --- a/components/DiffScheduleEvent.vue +++ b/components/DiffScheduleEvent.vue @@ -11,6 +11,12 @@ :after="event.name" :state /> + id name + timetableName host notice description @@ -32,6 +33,13 @@ v-model="event.name" > + + + + + + {{ event.id }} {{ event.name }} + {{ event.timetableName }} {{ event.host }} {{ event.notice }} {{ event.description }} @@ -157,6 +172,7 @@ function canEdit(event: ClientScheduleEvent) { } const newEventName = ref(""); +const newEventShortName = ref(""); const newEventHost = ref(""); const newEventNotice = ref(""); const newEventDescription = ref(""); @@ -178,6 +194,7 @@ function newEvent() { schedule.value, schedule.value.nextClientId--, newEventName.value, + newEventShortName.value, !newEventPublic.value, newEventHost.value, false, @@ -189,6 +206,7 @@ function newEvent() { ); schedule.value.events.add(event); newEventName.value = ""; + newEventShortName.value = ""; newEventHost.value = ""; newEventNotice.value = ""; newEventDescription.value = ""; diff --git a/components/Timetable.vue b/components/Timetable.vue index 403c77b..86e4af9 100644 --- a/components/Timetable.vue +++ b/components/Timetable.vue @@ -106,7 +106,7 @@ :title="cell.event?.name" > {{ cell.event?.notice ? "⚠️" : undefined }} - {{ cell.event?.name }} + {{ cell.event?.timetableName || cell.event?.name }} diff --git a/shared/types/api.ts b/shared/types/api.ts index eb192a4..b72ccb6 100644 --- a/shared/types/api.ts +++ b/shared/types/api.ts @@ -89,6 +89,7 @@ export type ApiScheduleEventSlot = z.infer; export const apiScheduleEventSchema = defineApiEntity({ name: z.string(), + timetableName: z.optional(z.string()), crew: z.optional(z.boolean()), host: z.optional(z.string()), cancelled: z.optional(z.boolean()), diff --git a/utils/client-schedule.nuxt.test.ts b/utils/client-schedule.nuxt.test.ts index 9222301..ae86fc4 100644 --- a/utils/client-schedule.nuxt.test.ts +++ b/utils/client-schedule.nuxt.test.ts @@ -21,10 +21,10 @@ function fixtureClientSchedule(multiSlot = false) { const events = [ 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( - 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([ diff --git a/utils/client-schedule.ts b/utils/client-schedule.ts index 17db51b..89aa041 100644 --- a/utils/client-schedule.ts +++ b/utils/client-schedule.ts @@ -133,6 +133,7 @@ export class ClientScheduleLocation extends ClientEntity { export class ClientScheduleEvent extends ClientEntity { schedule!: ClientSchedule; serverName: string; + serverTimetableName: string; serverCrew: boolean; serverHost: string; serverCancelled: boolean; @@ -146,6 +147,7 @@ export class ClientScheduleEvent extends ClientEntity { updatedAt: DateTime, deleted: boolean, public name: string, + public timetableName: string, public crew: boolean, public host: string, public cancelled: boolean, @@ -156,6 +158,7 @@ export class ClientScheduleEvent extends ClientEntity { ) { super(id, updatedAt, deleted); this.serverName = name; + this.serverTimetableName = timetableName; this.serverCrew = crew; this.serverHost = host; this.serverCancelled = cancelled; @@ -173,6 +176,7 @@ export class ClientScheduleEvent extends ClientEntity { return ( super.isModified() || this.name !== this.serverName + || this.timetableName !== this.serverTimetableName || this.crew !== this.serverCrew || this.host !== this.serverHost || this.cancelled !== this.serverCancelled @@ -191,6 +195,7 @@ export class ClientScheduleEvent extends ClientEntity { this.updatedAt = this.serverUpdatedAt;; this.deleted = this.serverDeleted;; this.name = this.serverName; + this.timetableName = this.serverTimetableName; this.crew = this.serverCrew; this.host = this.serverHost; this.cancelled = this.serverCancelled; @@ -210,6 +215,7 @@ export class ClientScheduleEvent extends ClientEntity { schedule: ClientSchedule, id: Id, name: string, + timetableName: string, crew: boolean, host: string, cancelled: boolean, @@ -224,6 +230,7 @@ export class ClientScheduleEvent extends ClientEntity { DateTime.fromMillis(ClientEntity.newEntityMillis, opts), false, name, + timetableName, crew, host, cancelled, @@ -245,6 +252,7 @@ export class ClientScheduleEvent extends ClientEntity { DateTime.fromISO(api.updatedAt, opts), api.deleted ?? false, api.name, + api.timetableName ?? "", api.crew ?? false, api.host ?? "", api.cancelled ?? false, @@ -263,6 +271,7 @@ export class ClientScheduleEvent extends ClientEntity { this.serverUpdatedAt = DateTime.fromISO(api.updatedAt, opts); this.serverDeleted = false; this.serverName = api.name; + this.serverTimetableName = api.timetableName ?? ""; this.serverCrew = api.crew ?? false; this.serverHost = api.host ?? ""; this.serverCancelled = api.cancelled ?? false; @@ -287,6 +296,7 @@ export class ClientScheduleEvent extends ClientEntity { id: this.id, updatedAt: toIso(this.updatedAt), name: this.name, + timetableName: this.timetableName || undefined, crew: this.crew || undefined, host: this.host || undefined, cancelled: this.cancelled || undefined, From a932cccfc007859f8ffcce3ae9126681c87d7dcf Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 12 Sep 2025 19:34:34 +0200 Subject: [PATCH 02/13] Add hook to script edit schedules in the client Expose the schedules in the schedules store as the window global owltideSchedules so that mass changes can easily be scripted by a programmer. --- stores/schedules.ts | 4 ++++ utils/client-schedule.nuxt.test.ts | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/stores/schedules.ts b/stores/schedules.ts index 9f120e6..9d0a097 100644 --- a/stores/schedules.ts +++ b/stores/schedules.ts @@ -19,6 +19,10 @@ export const useSchedulesStore = defineStore("schedules", () => { pendingSyncs: ref>(new Map()), }; + /* Expose schedules to the console on the client to make it easy to inspect and do scripted modifications. */ + if (import.meta.client) + (window as any).owltideSchedules = state.schedules; + const getters = { activeSchedule: computed(() => { if (state.activeScheduleId.value === undefined) diff --git a/utils/client-schedule.nuxt.test.ts b/utils/client-schedule.nuxt.test.ts index ae86fc4..d132636 100644 --- a/utils/client-schedule.nuxt.test.ts +++ b/utils/client-schedule.nuxt.test.ts @@ -174,7 +174,7 @@ describe("class ClientSchedule", () => { ], [ "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", From 400bb7bfe98a48891e07d803be056375a4233c23 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 16 Sep 2025 20:33:47 +0200 Subject: [PATCH 03/13] Add .flow class for spacing custom elements Add .flow class for when vertical spacing between elements is desired in the same way paragraphs are vertically spaced apart. --- assets/global.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/global.css b/assets/global.css index 3d4e0c0..4869d13 100644 --- a/assets/global.css +++ b/assets/global.css @@ -76,7 +76,7 @@ label>* { margin-inline-start: 0.5rem; } -:is(p, form, fieldset, pre, ul) + :is(p, form, fieldset, pre, ul) { +:is(p, form, fieldset, pre, ul, .flow) + :is(p, form, fieldset, pre, ul, .flow) { margin-block-start: 0.5rem; } From 6d93e99858fb55d9fc3f68c6d50b02210705b89a Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Tue, 16 Sep 2025 20:46:11 +0200 Subject: [PATCH 04/13] Fix edits to notice field not being shown in diff --- components/DiffScheduleEvent.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/components/DiffScheduleEvent.vue b/components/DiffScheduleEvent.vue index ee30c2e..6cf081e 100644 --- a/components/DiffScheduleEvent.vue +++ b/components/DiffScheduleEvent.vue @@ -29,6 +29,12 @@ :after='event.crew ? "No" : "Yes"' :state /> + Date: Tue, 16 Sep 2025 20:54:36 +0200 Subject: [PATCH 05/13] Treat description fields as markdown Support basic formatting in the display of the description fields to locations, events and shifts by rendering them as Markdown using the micromark library. --- components/CardEvent.vue | 15 ++- components/CardEventSlot.vue | 14 +- components/CardShift.vue | 15 ++- package.json | 1 + pages/schedule.vue | 8 +- pnpm-lock.yaml | 243 +++++++++++++++++++++++++++++++++-- 6 files changed, 282 insertions(+), 14 deletions(-) diff --git a/components/CardEvent.vue b/components/CardEvent.vue index baca351..cec96b9 100644 --- a/components/CardEvent.vue +++ b/components/CardEvent.vue @@ -16,7 +16,11 @@ {{ event.notice }}

-

{{ event.description ?? "No description provided" }}

+

{{ event.interested }} interested

@@ -56,12 +60,19 @@ diff --git a/composables/event-source.ts b/composables/event-source.ts index 838b879..0e06b17 100644 --- a/composables/event-source.ts +++ b/composables/event-source.ts @@ -2,12 +2,12 @@ SPDX-FileCopyrightText: © 2025 Hornwitser SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { ApiEvent } from "~/shared/types/api"; +import type { ApiEvent, ApiEventStreamMessage } from "~/shared/types/api"; interface AppEventMap { "open": Event, - "message": MessageEvent, - "update": MessageEvent, + "message": MessageEvent, + "event": MessageEvent, "error": Event, "close": Event, } @@ -18,12 +18,11 @@ class AppEventSource extends EventTarget { #forwardEvent(type: string) { this.#source!.addEventListener(type, event => { - if (type === "open" || type === "message" || type === "error") { - console.log("AppEventSource", event.type, event.data); + console.log("AppEventSource", event.type, event.data); + if (type === "open" || type === "error") { this.dispatchEvent(new Event(event.type)); - } else { - const data = event.data ? JSON.parse(event.data) as ApiEvent : undefined; - console.log("AppEventSource", event.type, data); + } else if (type === "message") { + const data = event.data ? JSON.parse(event.data) as ApiEventStreamMessage : undefined; if (data?.type === "connected") { this.#sourceSessionId = data.session?.id; } @@ -34,17 +33,27 @@ class AppEventSource extends EventTarget { source: event.source, ports: [...event.ports], })); + } else { + const data = event.data ? JSON.parse(event.data) as ApiEvent : undefined; + this.dispatchEvent(new MessageEvent(event.type, { + data, + origin: event.origin, + lastEventId: event.lastEventId, + source: event.source, + ports: [...event.ports], + })); } }); } - open(sessionId: number | undefined) { + open(sessionId: number | undefined, lastEventId: number) { console.log("Opening event source sid:", sessionId); this.#sourceSessionId = sessionId; - this.#source = new EventSource("/api/events"); + const query = new URLSearchParams({ lastEventId: String(lastEventId) }); + this.#source = new EventSource(`/api/events?${query}`); this.#forwardEvent("open"); this.#forwardEvent("message"); - this.#forwardEvent("update"); + this.#forwardEvent("event"); this.#forwardEvent("error"); } @@ -58,20 +67,20 @@ class AppEventSource extends EventTarget { } #connectRefs = 0; - connect(sessionId: number | undefined) { + connect(sessionId: number | undefined, lastEventId: number) { this.#connectRefs += 1; if (this.#source && this.#sourceSessionId !== sessionId) { this.close(); } if (!this.#source) { - this.open(sessionId); + this.open(sessionId, lastEventId); } } - reconnect(sessionId: number | undefined) { + reconnect(sessionId: number | undefined, lastEventId: number) { if (this.#source && this.#sourceSessionId !== sessionId) { this.close(); - this.open(sessionId); + this.open(sessionId, lastEventId); } } @@ -113,14 +122,15 @@ export const appEventSource = import.meta.client ? new AppEventSource() : null; export function useEventSource() { const sessionStore = useSessionStore(); + const eventsStore = useEventsStore(); onMounted(() => { console.log("useEventSource onMounted", sessionStore.id); - appEventSource!.connect(sessionStore.id); + appEventSource!.connect(sessionStore.id, eventsStore.lastEventId); }) watch(() => sessionStore.id, () => { console.log("useEventSource sessionStore.id change", sessionStore.id); - appEventSource!.reconnect(sessionStore.id); + appEventSource!.reconnect(sessionStore.id, eventsStore.lastEventId); }) onUnmounted(() => { diff --git a/docs/dev/server-sent-events.md b/docs/dev/server-sent-events.md index 82ff99b..d510c1e 100644 --- a/docs/dev/server-sent-events.md +++ b/docs/dev/server-sent-events.md @@ -11,3 +11,9 @@ To update in real time this application sends a `text/event-source` stream using Upon connecting a `"connect"` event is emitted with the session the connection was made under. This is the primary mechanism a user agent discovers its own session having been rotated into a new one, which also happens when the access level of the account associated with the session changes. After the `"connect"` event the user agent will start to receive updates to resources it has access to that has changed. There is no filtering for what resoucres the user agent receives updates for at the moment as there's not enough events to justify the complexity of server-side subscriptions and filtering. + +## Id and order + +Events are guaranteed to be delivered in order, and to maintain consistency the server provides the following guarantee: Any entities fetched after receiving a response from `/api/last-event-id` will include updates from all events up to and including the `id` received from the response. + +This means that a client can fetch an up to date and live representation of any API entity by first fetching the last event from `/api/last-event-id`, and then in parallel fetch any entities as well as opening the `/api/events` stream with the `lastEventId` query param set to the value received from the `/api/last-event-id` endpoint. diff --git a/server/api/admin/user.patch.ts b/server/api/admin/user.patch.ts index 25c559f..6f1c9a7 100644 --- a/server/api/admin/user.patch.ts +++ b/server/api/admin/user.patch.ts @@ -2,7 +2,7 @@ SPDX-FileCopyrightText: © 2025 Hornwitser SPDX-License-Identifier: AGPL-3.0-or-later */ -import { readSessions, readUsers, writeSessions, writeUsers } from "~/server/database"; +import { nextEventId, readSessions, readUsers, writeSessions, writeUsers } from "~/server/database"; import { apiUserPatchSchema } from "~/shared/types/api"; import { z } from "zod/v4-mini"; import { broadcastEvent } from "~/server/streams"; @@ -54,6 +54,7 @@ export default defineEventHandler(async (event) => { user.updatedAt = new Date().toISOString(); await writeUsers(users); broadcastEvent({ + id: await nextEventId(), type: "user-update", data: serverUserToApi(user), }); @@ -66,6 +67,7 @@ export default defineEventHandler(async (event) => { if (session.accountId === user.id) { session.rotatesAtMs = nowMs; broadcastEvent({ + id: await nextEventId(), type: "session-expired", sessionId: session.id, }); diff --git a/server/api/auth/account.delete.ts b/server/api/auth/account.delete.ts index 2e66d14..37a8770 100644 --- a/server/api/auth/account.delete.ts +++ b/server/api/auth/account.delete.ts @@ -5,6 +5,7 @@ import { readUsers, readSessions, readSubscriptions, writeUsers, writeSessions, writeSubscriptions, + nextEventId, } from "~/server/database"; import { broadcastEvent, cancelAccountStreams } from "~/server/streams"; @@ -24,6 +25,7 @@ export default defineEventHandler(async (event) => { ) { session.expiresAtMs = nowMs; broadcastEvent({ + id: await nextEventId(), type: "session-expired", sessionId: session.id, }); @@ -48,6 +50,7 @@ export default defineEventHandler(async (event) => { account.updatedAt = now; await writeUsers(users); await broadcastEvent({ + id: await nextEventId(), type: "user-update", data: { id: account.id, diff --git a/server/api/auth/account.post.ts b/server/api/auth/account.post.ts index e0b3be8..52ef502 100644 --- a/server/api/auth/account.post.ts +++ b/server/api/auth/account.post.ts @@ -2,7 +2,7 @@ SPDX-FileCopyrightText: © 2025 Hornwitser SPDX-License-Identifier: AGPL-3.0-or-later */ -import { readUsers, writeUsers, nextUserId, type ServerUser, readAuthenticationMethods, nextAuthenticationMethodId, writeAuthenticationMethods } from "~/server/database"; +import { readUsers, writeUsers, nextUserId, type ServerUser, readAuthenticationMethods, nextAuthenticationMethodId, writeAuthenticationMethods, nextEventId } from "~/server/database"; import { broadcastEvent } from "~/server/streams"; import type { ApiSession } from "~/shared/types/api"; @@ -88,6 +88,7 @@ export default defineEventHandler(async (event): Promise => { users.push(user); await writeUsers(users); await broadcastEvent({ + id: await nextEventId(), type: "user-update", data: user, }); diff --git a/server/api/events.ts b/server/api/events.ts index 12e1f3b..4415550 100644 --- a/server/api/events.ts +++ b/server/api/events.ts @@ -3,33 +3,46 @@ SPDX-License-Identifier: AGPL-3.0-or-later */ import { pipeline } from "node:stream"; -import { addStream, deleteStream } from "~/server/streams"; +import { createEventStream } from "~/server/streams"; export default defineEventHandler(async (event) => { const session = await getServerSession(event, false); - const encoder = new TextEncoder(); - const source = event.headers.get("x-forwarded-for"); - console.log(`starting event stream for ${source}`) - const stream = new TransformStream({ - transform(chunk, controller) { - controller.enqueue(encoder.encode(chunk)); - }, - flush(controller) { - console.log(`finished event stream for ${source}`); - deleteStream(stream.writable); - }, - // @ts-expect-error experimental API - cancel(reason) { - console.log(`cancelled event stream for ${source}`); - deleteStream(stream.writable); + let lastEventId: number | undefined; + const lastEventIdHeader = event.headers.get("Last-Event-ID"); + const lastEventIdQuery = getQuery(event)["lastEventId"]; + if (lastEventIdHeader) { + if (!/^[0-9]{1,15}$/.test(lastEventIdHeader)) { + throw createError({ + statusCode: 400, + statusMessage: "Bad Request", + message: "Malformed Last-Event-ID header", + }); } - }); - addStream(event, stream.writable, session); + lastEventId = Number.parseInt(lastEventIdHeader, 10); + } else if (lastEventIdQuery) { + if (typeof lastEventIdQuery !== "string" || !/^[0-9]{1,15}$/.test(lastEventIdQuery)) { + throw createError({ + statusCode: 400, + statusMessage: "Bad Request", + message: "Malformed lastEventId", + }); + } + lastEventId = Number.parseInt(lastEventIdQuery, 10); + } else { + throw createError({ + statusCode: 400, + statusMessage: "Bad Request", + message: "lastEventId is required", + }); + } + + const source = event.headers.get("x-forwarded-for") ?? ""; + const stream = await createEventStream(event, source, lastEventId, session); // Workaround to properly handle stream errors. See https://github.com/unjs/h3/issues/986 setHeader(event, "Access-Control-Allow-Origin", "*"); setHeader(event, "Content-Type", "text/event-stream"); - pipeline(stream.readable as unknown as NodeJS.ReadableStream, event.node.res, (err) => { /* ignore */ }); + pipeline(stream as unknown as NodeJS.ReadableStream, event.node.res, (err) => { /* ignore */ }); event._handled = true; }); diff --git a/server/api/last-event-id.ts b/server/api/last-event-id.ts new file mode 100644 index 0000000..6fd39ac --- /dev/null +++ b/server/api/last-event-id.ts @@ -0,0 +1,11 @@ +/* + SPDX-FileCopyrightText: © 2025 Hornwitser + SPDX-License-Identifier: AGPL-3.0-or-later +*/ + +import { readEvents } from "../database"; + +export default defineEventHandler(async (event) => { + const events = await readEvents(); + return events[events.length - 1]?. id ?? 0; +}); diff --git a/server/api/schedule.patch.ts b/server/api/schedule.patch.ts index 49d365b..517b6f0 100644 --- a/server/api/schedule.patch.ts +++ b/server/api/schedule.patch.ts @@ -3,7 +3,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later */ import { z } from "zod/v4-mini"; -import { readSchedule, writeSchedule } from "~/server/database"; +import { nextEventId, readSchedule, writeSchedule } from "~/server/database"; import { broadcastEvent } from "~/server/streams"; import { apiScheduleSchema } from "~/shared/types/api"; import { applyUpdatesToArray } from "~/shared/utils/update"; @@ -87,6 +87,7 @@ export default defineEventHandler(async (event) => { await writeSchedule(schedule); await broadcastEvent({ + id: await nextEventId(), type: "schedule-update", updatedFrom, data: update, diff --git a/server/database.ts b/server/database.ts index db5226a..7bba61b 100644 --- a/server/database.ts +++ b/server/database.ts @@ -3,7 +3,7 @@ SPDX-License-Identifier: AGPL-3.0-or-later */ import { readFile, unlink, writeFile } from "node:fs/promises"; -import type { ApiAuthenticationProvider, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api"; +import type { ApiAuthenticationProvider, ApiEvent, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api"; import type { Id } from "~/shared/types/common"; export interface ServerSession { @@ -50,6 +50,8 @@ const sessionsPath = "data/sessions.json"; const nextSessionIdPath = "data/next-session-id.json"; const authMethodPath = "data/auth-method.json"; const nextAuthenticationMethodIdPath = "data/auth-method-id.json" +const nextEventIdPath = "data/next-event-id.json"; +const eventsPath = "data/events.json"; async function remove(path: string) { try { @@ -168,3 +170,21 @@ export async function readAuthenticationMethods() { export async function writeAuthenticationMethods(authMethods: ServerAuthenticationMethod[]) { await writeFile(authMethodPath, JSON.stringify(authMethods, undefined, "\t") + "\n", "utf-8"); } + +export async function nextEventId() { + const nextId = await readJson(nextEventIdPath, 0); + await writeFile(nextEventIdPath, String(nextId + 1), "utf-8"); + return nextId; +} + +export async function writeNextEventId(nextId: number) { + await writeFile(nextEventIdPath, String(nextId), "utf-8"); +} + +export async function readEvents() { + return readJson(eventsPath, []) +} + +export async function writeEvents(events: ApiEvent[]) { + await writeFile(eventsPath, JSON.stringify(events, undefined, "\t") + "\n", "utf-8"); +} diff --git a/server/streams.ts b/server/streams.ts index 111c9a2..bc2dfa0 100644 --- a/server/streams.ts +++ b/server/streams.ts @@ -2,82 +2,134 @@ SPDX-FileCopyrightText: © 2025 Hornwitser SPDX-License-Identifier: AGPL-3.0-or-later */ -import { readUsers, type ServerSession } from "~/server/database"; -import type { ApiAccount, ApiEvent } from "~/shared/types/api"; +import { readEvents, writeEvents, readUsers, type ServerSession } from "~/server/database"; +import type { ApiAccount, ApiDisconnected, ApiEvent, ApiEventStreamMessage, ApiUserType } from "~/shared/types/api"; import { serverSessionToApi } from "./utils/session"; import { H3Event } from "h3"; -function sendMessage( - stream: WritableStream, - message: string, -) { - const writer = stream.getWriter(); - writer.ready - .then(() => writer.write(message)) - .catch(console.error) - .finally(() => writer.releaseLock()) - ; +const keepaliveTimeoutMs = 45e3; +const eventUpdateTimeMs = 1e3; + +class EventStream { + write!: (data: string) => void; + close!: (reason?: string) => void; + + constructor( + public sessionId: number | undefined, + public accountId: number | undefined, + public userType: ApiUserType | undefined, + public rotatesAtMs: number , + public lastKeepAliveMs: number, + public lastEventId: number, + ) { + } } -function sendMessageAndClose( - stream: WritableStream, - message: string, -) { - const writer = stream.getWriter(); - writer.ready - .then(() => { - writer.write(message); - writer.close(); - }).catch(console.error) - .finally(() => writer.releaseLock()) - ; -} - -const streams = new Map, { sessionId?: number, accountId?: number, rotatesAtMs: number }>(); - -let keepaliveInterval: ReturnType | null = null -export async function addStream( +export async function createEventStream( event: H3Event, - stream: WritableStream, + source: string, + lastEventId: number, session?: ServerSession, ) { - if (streams.size === 0) { - console.log("Starting keepalive") - keepaliveInterval = setInterval(sendKeepalive, 4000) - } const runtimeConfig = useRuntimeConfig(event); - streams.set(stream, { - sessionId: session?.id, - accountId: session?.accountId, - rotatesAtMs: session?.rotatesAtMs ?? Date.now() + runtimeConfig.sessionRotatesTimeout * 1000, + const now = Date.now(); + const events = (await readEvents()).filter(e => e.id > lastEventId); + const users = await readUsers(); + const apiSession = session ? await serverSessionToApi(event, session) : undefined; + let userType: ApiAccount["type"] | undefined; + if (session?.accountId !== undefined) { + userType = users.find(a => !a.deleted && a.id === session.accountId)?.type + } + const stream = new EventStream( + session?.id, + session?.accountId, + userType, + session?.rotatesAtMs ?? now + runtimeConfig.sessionRotatesTimeout * 1000, + now, + events[events.length - 1]?.id ?? lastEventId, + ); + + const readableStream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + stream.write = (data: string) => { + controller.enqueue(encoder.encode(data)); + } + stream.close = (reason?: string) => { + const data: ApiDisconnected = { + type: "disconnect", + reason, + }; + stream.write(`data: ${JSON.stringify(data)}\n\n`); + controller.close(); + deleteStream(stream); + }, + console.log(`Starting event stream for ${source}`) + addStream(stream); + }, + cancel(reason) { + console.log(`Cancelled event stream for ${source}:`, reason); + deleteStream(stream); + } }); // Produce a response immediately to avoid the reply waiting for content. - const update: ApiEvent = { + const update: ApiEventStreamMessage = { type: "connected", - session: session ? await serverSessionToApi(event, session) : undefined, + session: apiSession, }; - sendMessage(stream, `event: update\ndata: ${JSON.stringify(update)}\n\n`); + stream.write(`data: ${JSON.stringify(update)}\n\n`); + + /* + Send events since the provided lastEventId + + Warning: This have to happen either before addStream(stream) is + called, or as done here synchronously after it. Otherwise there's a + possibility of events being delivered out of order, which will break + the assumption made by the schedule updating logic. + */ + if (events.length) + console.log(`Sending ${events.length} event(s) to ${source}`); + for (const event of events) { + if (!sendEventToStream(stream, event)) { + break; + } + } + + return readableStream; } -export function deleteStream(stream: WritableStream) { + +let updateInterval: ReturnType | null = null +const streams = new Set(); +function addStream( + stream: EventStream, +) { + if (streams.size === 0) { + console.log("Starting event updates") + updateInterval = setInterval(sendEventUpdates, eventUpdateTimeMs) + } + streams.add(stream); +} +function deleteStream(stream: EventStream) { streams.delete(stream); if (streams.size === 0) { - console.log("Ending keepalive") - clearInterval(keepaliveInterval!); + console.log("Ending event updates") + clearInterval(updateInterval!); + updateInterval = null; } } export function cancelAccountStreams(accountId: number) { - for (const [stream, data] of streams) { - if (data.accountId === accountId) { - sendMessageAndClose(stream, `data: cancelled\n\n`); + for (const stream of streams.values()) { + if (stream.accountId === accountId) { + stream.close("cancelled"); } } } export function cancelSessionStreams(sessionId: number) { - for (const [stream, data] of streams) { - if (data.sessionId === sessionId) { - sendMessageAndClose(stream, `data: cancelled\n\n`); + for (const stream of streams.values()) { + if (stream.sessionId === sessionId) { + stream.close("cancelled"); } } } @@ -94,6 +146,7 @@ function encodeEvent(event: ApiEvent, userType: ApiAccount["type"] | undefined) if (event.type === "schedule-update") { if (!canSeeCrew(userType)) { event = { + id: event.id, type: event.type, updatedFrom: event.updatedFrom, data: filterSchedule(event.data), @@ -106,6 +159,7 @@ function encodeEvent(event: ApiEvent, userType: ApiAccount["type"] | undefined) || !event.data.deleted && event.data.type === "anonymous" && !canSeeAnonymous(userType) ) { event = { + id: event.id, type: event.type, data: { id: event.data.id, @@ -128,44 +182,67 @@ function encodeEvent(event: ApiEvent, userType: ApiAccount["type"] | undefined) } export async function broadcastEvent(event: ApiEvent) { - const id = Date.now(); - console.log(`broadcasting update to ${streams.size} clients`); - if (!streams.size) { - return; - } + const events = await readEvents(); + events.push(event); + await writeEvents(events); +} +function sendEventToStream(stream: EventStream, event: ApiEvent) { // Session expiry events cause the streams belonging to that session to be terminated if (event.type === "session-expired") { - cancelSessionStreams(event.sessionId); - return; - } - - const users = await readUsers(); - for (const [stream, streamData] of streams) { - // Account events are specially handled and only sent to the user they belong to. - if (event.type === "account-update") { - if (streamData.accountId === event.data.id) { - sendMessage(stream, `id: ${id}\nevent: update\ndata: ${JSON.stringify(event)}\n\n`); - } - - } else { - let userType: ApiAccount["type"] | undefined; - if (streamData.accountId !== undefined) { - userType = users.find(a => !a.deleted && a.id === streamData.accountId)?.type - } - const data = encodeEvent(event, userType) - sendMessage(stream, `id: ${id}\nevent: update\ndata: ${data}\n\n`); + if (stream.sessionId === event.sessionId) { + stream.close("session expired"); } + return false; } + + // Account events are specially handled and only sent to the user they belong to. + if (event.type === "account-update") { + if (stream.accountId === event.data.id) { + stream.write(`id: ${event.id}\nevent: event\ndata: ${JSON.stringify(event)}\n\n`); + } + return true; + } + + // All other events are encoded according to the user access level seeing it. + const data = encodeEvent(event, stream.userType) + stream.write(`id: ${event.id}\nevent: event\ndata: ${data}\n\n`); + return true; } -function sendKeepalive() { +async function sendEventUpdates() { + // Cancel streams that need to be rotated. const now = Date.now(); - for (const [stream, streamData] of streams) { - if (streamData.rotatesAtMs > now) { - sendMessage(stream, ": keepalive\n"); - } else { - sendMessageAndClose(stream, `data: cancelled\n\n`); + for (const stream of streams.values()) { + if (stream.rotatesAtMs < now) { + stream.close("session rotation"); + continue; + } + } + + // Send events. + const skipEventId = Math.min(...[...streams.values()].map(s => s.lastEventId)); + const events = (await readEvents()).filter(e => e.id > skipEventId); + if (events.length) + console.log(`broadcasting ${events.length} event(s) to ${streams.size} client(s)`); + for (const stream of streams.values()) { + for (const event of events) { + if (event.id > stream.lastEventId) { + stream.lastEventId = event.id; + stream.lastKeepAliveMs = now; + + if (!sendEventToStream(stream, event)) { + break; + } + } + } + } + + // Send Keepalives to streams with no activity. + for (const stream of streams.values()) { + if (stream.lastKeepAliveMs + keepaliveTimeoutMs < now) { + stream.write(": keepalive\n"); + stream.lastKeepAliveMs = now; } } } diff --git a/server/utils/schedule.ts b/server/utils/schedule.ts index e0e606a..c42960a 100644 --- a/server/utils/schedule.ts +++ b/server/utils/schedule.ts @@ -2,7 +2,7 @@ SPDX-FileCopyrightText: © 2025 Hornwitser SPDX-License-Identifier: AGPL-3.0-or-later */ -import { readSchedule, type ServerUser, writeSchedule } from '~/server/database'; +import { nextEventId, readSchedule, type ServerUser, writeSchedule } from '~/server/database'; import { broadcastEvent } from '~/server/streams'; import type { ApiSchedule, ApiTombstone } from '~/shared/types/api'; @@ -58,6 +58,7 @@ export async function updateScheduleInterestedCounts(users: ServerUser[]) { schedule.updatedAt = updatedFrom; await writeSchedule(schedule); await broadcastEvent({ + id: await nextEventId(), type: "schedule-update", updatedFrom, data: update, diff --git a/server/utils/session.ts b/server/utils/session.ts index dfba536..c605406 100644 --- a/server/utils/session.ts +++ b/server/utils/session.ts @@ -4,6 +4,7 @@ */ import type { H3Event } from "h3"; import { + nextEventId, nextSessionId, readSessions, readSubscriptions, @@ -34,6 +35,7 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio if (session) { session.expiresAtMs = Date.now(); broadcastEvent({ + id: await nextEventId(), type: "session-expired", sessionId, }); diff --git a/shared/types/api.ts b/shared/types/api.ts index b72ccb6..89355a9 100644 --- a/shared/types/api.ts +++ b/shared/types/api.ts @@ -155,27 +155,26 @@ export interface ApiUserDetails { } export interface ApiAccountUpdate { + id: Id, type: "account-update", data: ApiAccount, } -export interface ApiConnected { - type: "connected", - session?: ApiSession, -} - export interface ApiScheduleUpdate { + id: Id, type: "schedule-update", updatedFrom?: string, data: ApiSchedule | ApiTombstone, } export interface ApiSessionExpired { + id: Id, type: "session-expired", sessionId: Id, } export interface ApiUserUpdate { + id: Id, type: "user-update", updatedFrom?: string, data: ApiUser | ApiTombstone, @@ -183,8 +182,22 @@ export interface ApiUserUpdate { export type ApiEvent = | ApiAccountUpdate - | ApiConnected | ApiScheduleUpdate | ApiSessionExpired | ApiUserUpdate ; + +export interface ApiConnected { + type: "connected", + session?: ApiSession, +} + +export interface ApiDisconnected { + type: "disconnect", + reason?: string, +} + +export type ApiEventStreamMessage = + | ApiConnected + | ApiDisconnected +; diff --git a/stores/events.ts b/stores/events.ts new file mode 100644 index 0000000..f02ee20 --- /dev/null +++ b/stores/events.ts @@ -0,0 +1,31 @@ +/* + SPDX-FileCopyrightText: © 2025 Hornwitser + SPDX-License-Identifier: AGPL-3.0-or-later +*/ + +export const useEventsStore = defineStore("events", () => { + const state = { + lastEventId: ref(0), + }; + const getters = { + } + const actions = { + async fetchLastEventId() { + const requestFetch = useRequestFetch(); + state.lastEventId.value = await requestFetch("/api/last-event-id"); + } + } + + appEventSource?.addEventListener("event", (event) => { + if (event.data.id !== undefined) { + state.lastEventId.value = event.data.id + return; + } + }); + + return { + ...state, + ...getters, + ...actions, + }; +}); diff --git a/stores/schedules.ts b/stores/schedules.ts index 9d0a097..03c903e 100644 --- a/stores/schedules.ts +++ b/stores/schedules.ts @@ -96,7 +96,7 @@ export const useSchedulesStore = defineStore("schedules", () => { } }) - appEventSource?.addEventListener("update", (event) => { + appEventSource?.addEventListener("event", (event) => { if (event.data.type !== "schedule-update") { return; } diff --git a/stores/session.ts b/stores/session.ts index 380390b..882a6cb 100644 --- a/stores/session.ts +++ b/stores/session.ts @@ -57,7 +57,7 @@ export const useSessionStore = defineStore("session", () => { }, }; - appEventSource?.addEventListener("update", (event) => { + appEventSource?.addEventListener("message", (event) => { if (event.data.type !== "connected") { return; } diff --git a/stores/users.ts b/stores/users.ts index 57ae4f3..fdd78a3 100644 --- a/stores/users.ts +++ b/stores/users.ts @@ -75,7 +75,7 @@ export const useUsersStore = defineStore("users", () => { }, } - appEventSource?.addEventListener("update", (event) => { + appEventSource?.addEventListener("event", (event) => { if (event.data.type !== "user-update") { return; } From 0083696343086f8d64f792511c089e9aeb13fd26 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sat, 20 Sep 2025 20:43:11 +0200 Subject: [PATCH 07/13] Fix unscoped CSS leaking out The missing scoped attribute cause h2 headers to no longer have the expected top margin. Fix by adding the intended scope attribute. --- components/DiffSchedule.vue | 2 +- pages/admin/users/[id].vue | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/components/DiffSchedule.vue b/components/DiffSchedule.vue index 3175d5a..5bc2541 100644 --- a/components/DiffSchedule.vue +++ b/components/DiffSchedule.vue @@ -61,7 +61,7 @@ const shifts = computed(() => { }); - diff --git a/components/TableUsers.vue b/components/TableUsers.vue index 789f902..7021206 100644 --- a/components/TableUsers.vue +++ b/components/TableUsers.vue @@ -61,7 +61,3 @@ useEventSource(); const usersStore = useUsersStore(); - - diff --git a/pages/admin/index.vue b/pages/admin/index.vue index 116a189..86a1b5f 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -109,7 +109,3 @@ const tabs = [ { id: "database", title: "Database" }, ]; - - From 80cec7130853c2d5d2eeae3b44f4de4f0e8a01ea Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sat, 20 Sep 2025 23:04:16 +0200 Subject: [PATCH 09/13] Use sync access for the temp JSON file database Replace all async reads and writes to the JSON database with the sync reads and writes to prevent a data corruption race condition where two requests are processed at the same time and write to the same file, or one reads while the other writes causing read of partially written data. --- server/api/admin/database-export.post.ts | 12 +- server/api/admin/database-import-demo.post.ts | 8 +- server/api/admin/database-import.post.ts | 18 +-- server/api/admin/delete-database.post.ts | 2 +- server/api/admin/user.patch.ts | 12 +- server/api/auth/account.delete.ts | 16 +-- server/api/auth/account.patch.ts | 4 +- server/api/auth/account.post.ts | 14 +-- server/api/auth/ap/demo-login.post.ts | 4 +- server/api/auth/ap/telegram-login.post.ts | 4 +- server/api/auth/session.delete.ts | 2 +- server/api/last-event-id.ts | 2 +- server/api/schedule.patch.ts | 6 +- server/api/schedule.ts | 2 +- server/api/subscribe.post.ts | 4 +- server/api/unsubscribe.post.ts | 4 +- server/api/users/[id]/details.get.ts | 2 +- server/api/users/index.get.ts | 2 +- server/database.ts | 110 +++++++++--------- server/streams.ts | 10 +- server/utils/schedule.ts | 4 +- server/utils/session.ts | 30 ++--- server/web-push.ts | 4 +- 23 files changed, 138 insertions(+), 138 deletions(-) diff --git a/server/api/admin/database-export.post.ts b/server/api/admin/database-export.post.ts index 61fd34f..2aff8c3 100644 --- a/server/api/admin/database-export.post.ts +++ b/server/api/admin/database-export.post.ts @@ -9,11 +9,11 @@ export default defineEventHandler(async (event) => { setHeader(event, "Content-Disposition", 'attachment; filename="database-dump.json"'); setHeader(event, "Content-Type", "application/json; charset=utf-8"); return { - nextUserId: await readNextUserId(), - users: await readUsers(), - nextSessionId: await readNextSessionId(), - sessions: await readSessions(), - subscriptions: await readSubscriptions(), - schedule: await readSchedule(), + nextUserId: readNextUserId(), + users: readUsers(), + nextSessionId: readNextSessionId(), + sessions: readSessions(), + subscriptions: readSubscriptions(), + schedule: readSchedule(), }; }) diff --git a/server/api/admin/database-import-demo.post.ts b/server/api/admin/database-import-demo.post.ts index cab0eae..f002fb9 100644 --- a/server/api/admin/database-import-demo.post.ts +++ b/server/api/admin/database-import-demo.post.ts @@ -7,14 +7,14 @@ import { generateDemoSchedule, generateDemoAccounts } from "~/server/generate-de export default defineEventHandler(async (event) => { await requireServerSessionWithAdmin(event); const accounts = generateDemoAccounts(); - await writeUsers(accounts); - await writeSchedule(generateDemoSchedule()); - await writeAuthenticationMethods(accounts.map((user, index) => ({ + writeUsers(accounts); + writeSchedule(generateDemoSchedule()); + writeAuthenticationMethods(accounts.map((user, index) => ({ id: index, userId: user.id, provider: "demo", slug: user.name!, name: user.name!, }))); - await writeNextAuthenticationMethodId(Math.max(await nextAuthenticationMethodId(), accounts.length)); + writeNextAuthenticationMethodId(Math.max(nextAuthenticationMethodId(), accounts.length)); }) diff --git a/server/api/admin/database-import.post.ts b/server/api/admin/database-import.post.ts index f4e6019..aad3560 100644 --- a/server/api/admin/database-import.post.ts +++ b/server/api/admin/database-import.post.ts @@ -29,20 +29,20 @@ export default defineEventHandler(async (event) => { }); } - const currentNextUserId = await readNextUserId(); - await writeNextUserId(Math.max(currentNextUserId, snapshot.nextUserId)); - await writeUsers(snapshot.users); - const currentNextSessionId = await readNextSessionId(); - await writeNextSessionId(Math.max(currentNextSessionId, snapshot.nextSessionId)); - const currentSessions = new Map((await readSessions()).map(session => [session.id, session])); - await writeSessions(snapshot.sessions.filter(session => { + const currentNextUserId = readNextUserId(); + writeNextUserId(Math.max(currentNextUserId, snapshot.nextUserId)); + writeUsers(snapshot.users); + const currentNextSessionId = readNextSessionId(); + writeNextSessionId(Math.max(currentNextSessionId, snapshot.nextSessionId)); + const currentSessions = new Map((readSessions()).map(session => [session.id, session])); + writeSessions(snapshot.sessions.filter(session => { const current = currentSessions.get(session.id); // Only keep sessions that match the account id in both sets to avoid // resurrecting deleted sessions. This will still cause session cross // pollution if a snapshot from another instance is loaded here. return current?.accountId !== undefined && current.accountId === session.accountId; })); - await writeSubscriptions(snapshot.subscriptions); - await writeSchedule(snapshot.schedule); + writeSubscriptions(snapshot.subscriptions); + writeSchedule(snapshot.schedule); await sendRedirect(event, "/"); }) diff --git a/server/api/admin/delete-database.post.ts b/server/api/admin/delete-database.post.ts index 395f607..fb2c836 100644 --- a/server/api/admin/delete-database.post.ts +++ b/server/api/admin/delete-database.post.ts @@ -6,5 +6,5 @@ import { deleteDatabase } from "~/server/database"; export default defineEventHandler(async (event) => { await requireServerSessionWithAdmin(event); - await deleteDatabase(); + deleteDatabase(); }) diff --git a/server/api/admin/user.patch.ts b/server/api/admin/user.patch.ts index 6f1c9a7..548f924 100644 --- a/server/api/admin/user.patch.ts +++ b/server/api/admin/user.patch.ts @@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => { }); } - const users = await readUsers(); + const users = readUsers(); const user = users.find(user => user.id === patch.id); if (!user || user.deleted) { throw createError({ @@ -52,28 +52,28 @@ export default defineEventHandler(async (event) => { user.name = patch.name; } user.updatedAt = new Date().toISOString(); - await writeUsers(users); + writeUsers(users); broadcastEvent({ - id: await nextEventId(), + id: nextEventId(), type: "user-update", data: serverUserToApi(user), }); // Rotate sessions with the user in it if the access changed if (accessChanged) { - const sessions = await readSessions(); + const sessions = readSessions(); const nowMs = Date.now(); for (const session of sessions) { if (session.accountId === user.id) { session.rotatesAtMs = nowMs; broadcastEvent({ - id: await nextEventId(), + id: nextEventId(), type: "session-expired", sessionId: session.id, }); } } - await writeSessions(sessions); + writeSessions(sessions); } // Update Schedule counts. diff --git a/server/api/auth/account.delete.ts b/server/api/auth/account.delete.ts index 37a8770..fb916ea 100644 --- a/server/api/auth/account.delete.ts +++ b/server/api/auth/account.delete.ts @@ -11,11 +11,11 @@ import { broadcastEvent, cancelAccountStreams } from "~/server/streams"; export default defineEventHandler(async (event) => { const serverSession = await requireServerSessionWithUser(event); - let users = await readUsers(); + let users = readUsers(); // Expire sessions for this user const expiredSessionIds = new Set(); - let sessions = await readSessions(); + let sessions = readSessions(); const nowMs = Date.now(); for (const session of sessions) { if ( @@ -25,7 +25,7 @@ export default defineEventHandler(async (event) => { ) { session.expiresAtMs = nowMs; broadcastEvent({ - id: await nextEventId(), + id: nextEventId(), type: "session-expired", sessionId: session.id, }); @@ -33,24 +33,24 @@ export default defineEventHandler(async (event) => { } } cancelAccountStreams(serverSession.accountId); - await writeSessions(sessions); + writeSessions(sessions); await deleteCookie(event, "session"); // Remove subscriptions for this user - let subscriptions = await readSubscriptions(); + let subscriptions = readSubscriptions(); subscriptions = subscriptions.filter( subscription => !expiredSessionIds.has(subscription.sessionId) ); - await writeSubscriptions(subscriptions); + writeSubscriptions(subscriptions); // Remove the user const account = users.find(user => user.id === serverSession.accountId)!; const now = new Date(nowMs).toISOString(); account.deleted = true; account.updatedAt = now; - await writeUsers(users); + writeUsers(users); await broadcastEvent({ - id: await nextEventId(), + id: nextEventId(), type: "user-update", data: { id: account.id, diff --git a/server/api/auth/account.patch.ts b/server/api/auth/account.patch.ts index 75c65ea..d468dcc 100644 --- a/server/api/auth/account.patch.ts +++ b/server/api/auth/account.patch.ts @@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => { } } - const users = await readUsers(); + const users = readUsers(); const account = users.find(user => user.id === session.accountId); if (!account) { throw Error("Account does not exist"); @@ -70,7 +70,7 @@ export default defineEventHandler(async (event) => { else delete account.locale; } - await writeUsers(users); + writeUsers(users); // Update Schedule counts. await updateScheduleInterestedCounts(users); diff --git a/server/api/auth/account.post.ts b/server/api/auth/account.post.ts index 52ef502..6be7cd5 100644 --- a/server/api/auth/account.post.ts +++ b/server/api/auth/account.post.ts @@ -18,7 +18,7 @@ export default defineEventHandler(async (event): Promise => { const body = await readBody(event); const name = body?.name; - const users = await readUsers(); + const users = readUsers(); let user: ServerUser; if (typeof name === "string") { if (name === "") { @@ -36,7 +36,7 @@ export default defineEventHandler(async (event): Promise => { const firstUser = users.every(user => user.type === "anonymous"); user = { - id: await nextUserId(), + id: nextUserId(), updatedAt: new Date().toISOString(), type: firstUser ? "admin" : "regular", name, @@ -44,7 +44,7 @@ export default defineEventHandler(async (event): Promise => { } else if (name === undefined) { user = { - id: await nextUserId(), + id: nextUserId(), updatedAt: new Date().toISOString(), type: "anonymous", }; @@ -76,19 +76,19 @@ export default defineEventHandler(async (event): Promise => { }); } authMethods.push({ - id: await nextAuthenticationMethodId(), + id: nextAuthenticationMethodId(), userId: user.id, provider: session.authenticationProvider, slug: session.authenticationSlug!, name: session.authenticationName!, }) - await writeAuthenticationMethods(authMethods); + writeAuthenticationMethods(authMethods); } users.push(user); - await writeUsers(users); + writeUsers(users); await broadcastEvent({ - id: await nextEventId(), + id: nextEventId(), type: "user-update", data: user, }); diff --git a/server/api/auth/ap/demo-login.post.ts b/server/api/auth/ap/demo-login.post.ts index ffd07de..193c619 100644 --- a/server/api/auth/ap/demo-login.post.ts +++ b/server/api/auth/ap/demo-login.post.ts @@ -24,11 +24,11 @@ export default defineEventHandler(async (event) => { }); } - const authMethods = await readAuthenticationMethods(); + const authMethods = readAuthenticationMethods(); const method = authMethods.find(method => method.provider === "demo" && method.slug === slug); let session; if (method) { - const users = await readUsers(); + const users = readUsers(); const account = users.find(user => !user.deleted && user.id === method.userId); session = await setServerSession(event, account); } else { diff --git a/server/api/auth/ap/telegram-login.post.ts b/server/api/auth/ap/telegram-login.post.ts index 25a610b..062b2d7 100644 --- a/server/api/auth/ap/telegram-login.post.ts +++ b/server/api/auth/ap/telegram-login.post.ts @@ -84,11 +84,11 @@ export default defineEventHandler(async (event): Promise => { } const slug = String(data.authData.id); - const authMethods = await readAuthenticationMethods(); + const authMethods = readAuthenticationMethods(); const method = authMethods.find(method => method.provider === "telegram" && method.slug === slug); let session; if (method) { - const users = await readUsers(); + const users = readUsers(); const account = users.find(user => !user.deleted && user.id === method.userId); session = await setServerSession(event, account); } else { diff --git a/server/api/auth/session.delete.ts b/server/api/auth/session.delete.ts index df4bfd9..038476b 100644 --- a/server/api/auth/session.delete.ts +++ b/server/api/auth/session.delete.ts @@ -8,7 +8,7 @@ import { cancelSessionStreams } from "~/server/streams"; export default defineEventHandler(async (event) => { const session = await getServerSession(event, true); if (session) { - const users = await readUsers(); + const users = readUsers(); const account = users.find(user => user.id === session.accountId); if (account?.type === "anonymous") { throw createError({ diff --git a/server/api/last-event-id.ts b/server/api/last-event-id.ts index 6fd39ac..b5193c0 100644 --- a/server/api/last-event-id.ts +++ b/server/api/last-event-id.ts @@ -6,6 +6,6 @@ import { readEvents } from "../database"; export default defineEventHandler(async (event) => { - const events = await readEvents(); + const events = readEvents(); return events[events.length - 1]?. id ?? 0; }); diff --git a/server/api/schedule.patch.ts b/server/api/schedule.patch.ts index 517b6f0..544f2f3 100644 --- a/server/api/schedule.patch.ts +++ b/server/api/schedule.patch.ts @@ -35,7 +35,7 @@ export default defineEventHandler(async (event) => { }); } - const schedule = await readSchedule(); + const schedule = readSchedule(); if (schedule.deleted) { throw createError({ @@ -85,9 +85,9 @@ export default defineEventHandler(async (event) => { applyUpdatesToArray(update.shifts, schedule.shifts = schedule.shifts ?? []); } - await writeSchedule(schedule); + writeSchedule(schedule); await broadcastEvent({ - id: await nextEventId(), + id: nextEventId(), type: "schedule-update", updatedFrom, data: update, diff --git a/server/api/schedule.ts b/server/api/schedule.ts index a9ab848..14235cf 100644 --- a/server/api/schedule.ts +++ b/server/api/schedule.ts @@ -6,6 +6,6 @@ import { readSchedule } from "~/server/database"; export default defineEventHandler(async (event) => { const session = await getServerSession(event, false); - const schedule = await readSchedule(); + const schedule = readSchedule(); return canSeeCrew(session?.access) ? schedule : filterSchedule(schedule); }); diff --git a/server/api/subscribe.post.ts b/server/api/subscribe.post.ts index 9c4a76a..a12a6c4 100644 --- a/server/api/subscribe.post.ts +++ b/server/api/subscribe.post.ts @@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => { message: z.prettifyError(error), }); } - const subscriptions = await readSubscriptions(); + const subscriptions = readSubscriptions(); const existingIndex = subscriptions.findIndex( sub => sub.type === "push" && sub.sessionId === session.id ); @@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => { } else { subscriptions.push(subscription); } - await writeSubscriptions(subscriptions); + writeSubscriptions(subscriptions); if (existingIndex !== -1) { return { message: "Existing subscription refreshed."}; } diff --git a/server/api/unsubscribe.post.ts b/server/api/unsubscribe.post.ts index 61a3d51..9e47e87 100644 --- a/server/api/unsubscribe.post.ts +++ b/server/api/unsubscribe.post.ts @@ -6,7 +6,7 @@ import { readSubscriptions, writeSubscriptions } from "~/server/database"; export default defineEventHandler(async (event) => { const session = await requireServerSessionWithUser(event); - const subscriptions = await readSubscriptions(); + const subscriptions = readSubscriptions(); const existingIndex = subscriptions.findIndex( sub => sub.type === "push" && sub.sessionId === session.id ); @@ -15,6 +15,6 @@ export default defineEventHandler(async (event) => { } else { return { message: "No subscription registered."}; } - await writeSubscriptions(subscriptions); + writeSubscriptions(subscriptions); return { message: "Existing subscription removed."}; }); diff --git a/server/api/users/[id]/details.get.ts b/server/api/users/[id]/details.get.ts index 4c6e07d..efc4c74 100644 --- a/server/api/users/[id]/details.get.ts +++ b/server/api/users/[id]/details.get.ts @@ -13,7 +13,7 @@ const detailsSchema = z.object({ export default defineEventHandler(async (event) => { await requireServerSessionWithAdmin(event); - const users = await readUsers(); + const users = readUsers(); const { success, error, data: params } = detailsSchema.safeParse(getRouterParams(event)); if (!success) { throw createError({ diff --git a/server/api/users/index.get.ts b/server/api/users/index.get.ts index f6365fd..1a3bb86 100644 --- a/server/api/users/index.get.ts +++ b/server/api/users/index.get.ts @@ -6,7 +6,7 @@ import { readUsers } from "~/server/database" export default defineEventHandler(async (event) => { const session = await requireServerSessionWithUser(event); - const users = await readUsers(); + const users = readUsers(); if (session.access === "admin") { return users.map(serverUserToApi); diff --git a/server/database.ts b/server/database.ts index 7bba61b..b3899a8 100644 --- a/server/database.ts +++ b/server/database.ts @@ -2,7 +2,7 @@ SPDX-FileCopyrightText: © 2025 Hornwitser SPDX-License-Identifier: AGPL-3.0-or-later */ -import { readFile, unlink, writeFile } from "node:fs/promises"; +import { readFileSync, writeFileSync, unlinkSync } from "node:fs"; import type { ApiAuthenticationProvider, ApiEvent, ApiSchedule, ApiSubscription, ApiUserType } from "~/shared/types/api"; import type { Id } from "~/shared/types/common"; @@ -53,9 +53,9 @@ const nextAuthenticationMethodIdPath = "data/auth-method-id.json" const nextEventIdPath = "data/next-event-id.json"; const eventsPath = "data/events.json"; -async function remove(path: string) { +function remove(path: string) { try { - await unlink(path); + unlinkSync(path); } catch (err: any) { if (err.code !== "ENOENT") { throw err; @@ -63,17 +63,17 @@ async function remove(path: string) { } } -export async function deleteDatabase() { - await remove(schedulePath); - await remove(subscriptionsPath); - await remove(usersPath); - await remove(sessionsPath); +export function deleteDatabase() { + remove(schedulePath); + remove(subscriptionsPath); + remove(usersPath); + remove(sessionsPath); } -async function readJson(filePath: string, fallback: T) { +function readJson(filePath: string, fallback: T) { let data: T extends () => infer R ? R : T; try { - data = JSON.parse(await readFile(filePath, "utf-8")); + data = JSON.parse(readFileSync(filePath, "utf-8")); } catch (err: any) { if (err.code !== "ENOENT") throw err; @@ -82,19 +82,19 @@ async function readJson(filePath: string, fallback: T) { return data; } -export async function readSchedule() { +export function readSchedule() { return readJson(schedulePath, (): ApiSchedule => ({ id: 111, updatedAt: new Date().toISOString(), })); } -export async function writeSchedule(schedule: ApiSchedule) { - await writeFile(schedulePath, JSON.stringify(schedule, undefined, "\t") + "\n", "utf-8"); +export function writeSchedule(schedule: ApiSchedule) { + writeFileSync(schedulePath, JSON.stringify(schedule, undefined, "\t") + "\n", "utf-8"); } -export async function readSubscriptions() { - let subscriptions = await readJson(subscriptionsPath, []); +export function readSubscriptions() { + let subscriptions = readJson(subscriptionsPath, []); if (subscriptions.length && "keys" in subscriptions[0]) { // Discard old format subscriptions = []; @@ -102,89 +102,89 @@ export async function readSubscriptions() { return subscriptions; } -export async function writeSubscriptions(subscriptions: ApiSubscription[]) { - await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8"); +export function writeSubscriptions(subscriptions: ApiSubscription[]) { + writeFileSync(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8"); } -export async function readNextUserId() { - return await readJson(nextUserIdPath, 0); +export function readNextUserId() { + return readJson(nextUserIdPath, 0); } -export async function writeNextUserId(nextId: number) { - await writeFile(nextUserIdPath, String(nextId), "utf-8"); +export function writeNextUserId(nextId: number) { + writeFileSync(nextUserIdPath, String(nextId), "utf-8"); } -export async function nextUserId() { - let nextId = await readJson(nextUserIdPath, 0); +export function nextUserId() { + let nextId = readJson(nextUserIdPath, 0); if (nextId === 0) { - nextId = Math.max(...(await readUsers()).map(user => user.id), -1) + 1; + nextId = Math.max(...(readUsers()).map(user => user.id), -1) + 1; } - await writeFile(nextUserIdPath, String(nextId + 1), "utf-8"); + writeFileSync(nextUserIdPath, String(nextId + 1), "utf-8"); return nextId; } -export async function readUsers() { - return await readJson(usersPath, (): ServerUser[] => []); +export function readUsers() { + return readJson(usersPath, (): ServerUser[] => []); } -export async function writeUsers(users: ServerUser[]) { - await writeFile(usersPath, JSON.stringify(users, undefined, "\t") + "\n", "utf-8"); +export function writeUsers(users: ServerUser[]) { + writeFileSync(usersPath, JSON.stringify(users, undefined, "\t") + "\n", "utf-8"); } -export async function readNextSessionId() { - return await readJson(nextSessionIdPath, 0); +export function readNextSessionId() { + return readJson(nextSessionIdPath, 0); } -export async function writeNextSessionId(nextId: number) { - await writeFile(nextSessionIdPath, String(nextId), "utf-8"); +export function writeNextSessionId(nextId: number) { + writeFileSync(nextSessionIdPath, String(nextId), "utf-8"); } -export async function nextSessionId() { - const nextId = await readJson(nextSessionIdPath, 0); - await writeFile(nextSessionIdPath, String(nextId + 1), "utf-8"); +export function nextSessionId() { + const nextId = readJson(nextSessionIdPath, 0); + writeFileSync(nextSessionIdPath, String(nextId + 1), "utf-8"); return nextId; } -export async function readSessions() { +export function readSessions() { return readJson(sessionsPath, []) } -export async function writeSessions(sessions: ServerSession[]) { - await writeFile(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8"); +export function writeSessions(sessions: ServerSession[]) { + writeFileSync(sessionsPath, JSON.stringify(sessions, undefined, "\t") + "\n", "utf-8"); } -export async function nextAuthenticationMethodId() { - const nextId = await readJson(nextAuthenticationMethodIdPath, 0); - await writeFile(nextAuthenticationMethodIdPath, String(nextId + 1), "utf-8"); +export function nextAuthenticationMethodId() { + const nextId = readJson(nextAuthenticationMethodIdPath, 0); + writeFileSync(nextAuthenticationMethodIdPath, String(nextId + 1), "utf-8"); return nextId; } -export async function writeNextAuthenticationMethodId(nextId: number) { - await writeFile(nextAuthenticationMethodIdPath, String(nextId), "utf-8"); +export function writeNextAuthenticationMethodId(nextId: number) { + writeFileSync(nextAuthenticationMethodIdPath, String(nextId), "utf-8"); } -export async function readAuthenticationMethods() { +export function readAuthenticationMethods() { return readJson(authMethodPath, []) } -export async function writeAuthenticationMethods(authMethods: ServerAuthenticationMethod[]) { - await writeFile(authMethodPath, JSON.stringify(authMethods, undefined, "\t") + "\n", "utf-8"); +export function writeAuthenticationMethods(authMethods: ServerAuthenticationMethod[]) { + writeFileSync(authMethodPath, JSON.stringify(authMethods, undefined, "\t") + "\n", "utf-8"); } -export async function nextEventId() { - const nextId = await readJson(nextEventIdPath, 0); - await writeFile(nextEventIdPath, String(nextId + 1), "utf-8"); +export function nextEventId() { + const nextId = readJson(nextEventIdPath, 0); + writeFileSync(nextEventIdPath, String(nextId + 1), "utf-8"); return nextId; } -export async function writeNextEventId(nextId: number) { - await writeFile(nextEventIdPath, String(nextId), "utf-8"); +export function writeNextEventId(nextId: number) { + writeFileSync(nextEventIdPath, String(nextId), "utf-8"); } -export async function readEvents() { +export function readEvents() { return readJson(eventsPath, []) } -export async function writeEvents(events: ApiEvent[]) { - await writeFile(eventsPath, JSON.stringify(events, undefined, "\t") + "\n", "utf-8"); +export function writeEvents(events: ApiEvent[]) { + writeFileSync(eventsPath, JSON.stringify(events, undefined, "\t") + "\n", "utf-8"); } diff --git a/server/streams.ts b/server/streams.ts index bc2dfa0..aca024a 100644 --- a/server/streams.ts +++ b/server/streams.ts @@ -33,8 +33,8 @@ export async function createEventStream( ) { const runtimeConfig = useRuntimeConfig(event); const now = Date.now(); - const events = (await readEvents()).filter(e => e.id > lastEventId); - const users = await readUsers(); + const events = (readEvents()).filter(e => e.id > lastEventId); + const users = readUsers(); const apiSession = session ? await serverSessionToApi(event, session) : undefined; let userType: ApiAccount["type"] | undefined; if (session?.accountId !== undefined) { @@ -182,9 +182,9 @@ function encodeEvent(event: ApiEvent, userType: ApiAccount["type"] | undefined) } export async function broadcastEvent(event: ApiEvent) { - const events = await readEvents(); + const events = readEvents(); events.push(event); - await writeEvents(events); + writeEvents(events); } function sendEventToStream(stream: EventStream, event: ApiEvent) { @@ -222,7 +222,7 @@ async function sendEventUpdates() { // Send events. const skipEventId = Math.min(...[...streams.values()].map(s => s.lastEventId)); - const events = (await readEvents()).filter(e => e.id > skipEventId); + const events = (readEvents()).filter(e => e.id > skipEventId); if (events.length) console.log(`broadcasting ${events.length} event(s) to ${streams.size} client(s)`); for (const stream of streams.values()) { diff --git a/server/utils/schedule.ts b/server/utils/schedule.ts index c42960a..4e263a7 100644 --- a/server/utils/schedule.ts +++ b/server/utils/schedule.ts @@ -20,7 +20,7 @@ export async function updateScheduleInterestedCounts(users: ServerUser[]) { eventSlotCounts.set(id, (eventSlotCounts.get(id) ?? 0) + 1); } - const schedule = await readSchedule(); + const schedule = readSchedule(); if (schedule.deleted) { throw new Error("Deleted schedule not implemented"); } @@ -58,7 +58,7 @@ export async function updateScheduleInterestedCounts(users: ServerUser[]) { schedule.updatedAt = updatedFrom; await writeSchedule(schedule); await broadcastEvent({ - id: await nextEventId(), + id: nextEventId(), type: "schedule-update", updatedFrom, data: update, diff --git a/server/utils/session.ts b/server/utils/session.ts index c605406..d56bc10 100644 --- a/server/utils/session.ts +++ b/server/utils/session.ts @@ -19,11 +19,11 @@ import type { ApiAuthenticationProvider, ApiSession } from "~/shared/types/api"; import { serverUserToApiAccount } from "./user"; async function removeSessionSubscription(sessionId: number) { - const subscriptions = await readSubscriptions(); + const subscriptions = readSubscriptions(); const index = subscriptions.findIndex(subscription => subscription.sessionId === sessionId); if (index !== -1) { subscriptions.splice(index, 1); - await writeSubscriptions(subscriptions); + writeSubscriptions(subscriptions); } } @@ -35,7 +35,7 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio if (session) { session.expiresAtMs = Date.now(); broadcastEvent({ - id: await nextEventId(), + id: nextEventId(), type: "session-expired", sessionId, }); @@ -47,9 +47,9 @@ async function clearServerSessionInternal(event: H3Event, sessions: ServerSessio } export async function clearServerSession(event: H3Event) { - const sessions = await readSessions(); + const sessions = readSessions(); if (await clearServerSessionInternal(event, sessions)) { - await writeSessions(sessions); + writeSessions(sessions); } deleteCookie(event, "session"); } @@ -61,7 +61,7 @@ export async function setServerSession( authenticationSlug?: string, authenticationName?: string, ) { - const sessions = await readSessions(); + const sessions = readSessions(); const runtimeConfig = useRuntimeConfig(event); await clearServerSessionInternal(event, sessions); @@ -78,14 +78,14 @@ export async function setServerSession( }; sessions.push(newSession); - await writeSessions(sessions); + writeSessions(sessions); await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout) return newSession; } async function rotateSession(event: H3Event, sessions: ServerSession[], session: ServerSession) { const runtimeConfig = useRuntimeConfig(event); - const users = await readUsers(); + const users = readUsers(); const account = users.find(user => !user.deleted && user.id === session.accountId); const now = Date.now(); const newSession: ServerSession = { @@ -94,12 +94,12 @@ async function rotateSession(event: H3Event, sessions: ServerSession[], session: // Authentication provider is removed to avoid possibility of an infinite delay before using it. rotatesAtMs: now + runtimeConfig.sessionRotatesTimeout * 1000, discardAtMs: now + runtimeConfig.sessionDiscardTimeout * 1000, - id: await nextSessionId(), + id: nextSessionId(), }; session.successor = newSession.id; session.expiresAtMs = Date.now() + 10 * 1000; sessions.push(newSession); - await writeSessions(sessions); + writeSessions(sessions); await setSignedCookie(event, "session", String(newSession.id), runtimeConfig.sessionDiscardTimeout) return newSession; } @@ -108,7 +108,7 @@ export async function getServerSession(event: H3Event, ignoreTaken: boolean) { const sessionCookie = await getSignedCookie(event, "session"); if (sessionCookie) { const sessionId = parseInt(sessionCookie, 10); - const sessions = await readSessions(); + const sessions = readSessions(); const session = sessions.find(session => session.id === sessionId); if (session) { const nowMs = Date.now(); @@ -148,7 +148,7 @@ export async function requireServerSession(event: H3Event, message: string) { export async function requireServerSessionWithUser(event: H3Event) { const message = "User session required"; const session = await requireServerSession(event, message); - const users = await readUsers(); + const users = readUsers(); const account = users.find(user => user.id === session.accountId); if (session.accountId === undefined || !account || account.deleted) throw createError({ @@ -163,7 +163,7 @@ export async function requireServerSessionWithUser(event: H3Event) { export async function requireServerSessionWithAdmin(event: H3Event) { const message = "Admin session required"; const session = await requireServerSession(event, message); - const users = await readUsers(); + const users = readUsers(); const account = users.find(user => user.id === session.accountId); if (session.access !== "admin" || account?.type !== "admin") { throw createError({ @@ -176,9 +176,9 @@ export async function requireServerSessionWithAdmin(event: H3Event) { } export async function serverSessionToApi(event: H3Event, session: ServerSession): Promise { - const users = await readUsers(); + const users = readUsers(); const account = users.find(user => !user.deleted && user.id === session.accountId); - const subscriptions = await readSubscriptions(); + const subscriptions = readSubscriptions(); const push = Boolean( subscriptions.find(sub => sub.type === "push" && sub.sessionId === session.id) ); diff --git a/server/web-push.ts b/server/web-push.ts index 06f5216..ea09623 100644 --- a/server/web-push.ts +++ b/server/web-push.ts @@ -33,7 +33,7 @@ async function useVapidDetails(event: H3Event) { export async function sendPush(event: H3Event, title: string, body: string) { const vapidDetails = await useVapidDetails(event); const payload = JSON.stringify({ title, body }); - const subscriptions = await readSubscriptions(); + const subscriptions = readSubscriptions(); console.log(`Sending "${payload}" to ${subscriptions.length} subscribers`); const removeIndexes = []; for (let index = 0; index < subscriptions.length; index += 1) { @@ -65,7 +65,7 @@ export async function sendPush(event: H3Event, title: string, body: string) { for (const index of removeIndexes) { subscriptions.splice(index, 1); } - await writeSubscriptions(subscriptions); + writeSubscriptions(subscriptions); } console.log("Push notices sent"); } From 5d4cdb7b83cebb9a74178984e47bfa9faca57801 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sun, 21 Sep 2025 22:15:11 +0200 Subject: [PATCH 10/13] Fix session-expired event causing events to stop If a session-expired event is hit that does not match the current session then it should be ignored and event processing continue without it. This was incorrectly implemented and instead event processing would stop when any session-expired event was hit, not just the ones matching the current session. Fix logic to properly ignore session-expired events when it doesn't match the current session. --- server/streams.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/streams.ts b/server/streams.ts index aca024a..dd312da 100644 --- a/server/streams.ts +++ b/server/streams.ts @@ -192,8 +192,9 @@ function sendEventToStream(stream: EventStream, event: ApiEvent) { if (event.type === "session-expired") { if (stream.sessionId === event.sessionId) { stream.close("session expired"); + return false; } - return false; + return true; } // Account events are specially handled and only sent to the user they belong to. From 7314b26c77114ceb365d5ea4eacb04442d10d1b7 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sun, 21 Sep 2025 22:27:28 +0200 Subject: [PATCH 11/13] Fix tab visuals in Firefox The rendering of the tabs would not include the spacer in Firefox for if the width was not set for some reason. --- components/Tabs.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/Tabs.vue b/components/Tabs.vue index 0d2ee34..37bf429 100644 --- a/components/Tabs.vue +++ b/components/Tabs.vue @@ -55,7 +55,6 @@ nav { } .tab { display: flex; - flex-wrap: wrap; } .tab.active { padding-block-start: 1px; @@ -65,6 +64,7 @@ nav { } .tab .spacer { flex: 1 0 0.75rem; + width: 0.75rem; } .tab .flap, .tab .spacer { From a32a49b281e7d08f76154e7ec29d46acd2e518f3 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Sun, 21 Sep 2025 23:15:10 +0200 Subject: [PATCH 12/13] Add UI for setting cancelled status Add indications in event cards, event slot cards and the timetable for an event or event slot being cancelled by striking it through and dimming the text colour. And a checkbox in the event and event slot list to edit the cancelled status. And a diff entry for the cancelled status on events and event slots. --- components/CardEvent.vue | 20 +++++++++++++++++--- components/CardEventSlot.vue | 13 +++++++++++-- components/DiffScheduleEvent.vue | 6 ++++++ components/DiffScheduleEventSlot.vue | 6 ++++++ components/TableScheduleEventSlots.vue | 9 +++++++++ components/TableScheduleEvents.vue | 9 +++++++++ components/Timetable.vue | 11 ++++++++++- 7 files changed, 68 insertions(+), 6 deletions(-) diff --git a/components/CardEvent.vue b/components/CardEvent.vue index cec96b9..0d90894 100644 --- a/components/CardEvent.vue +++ b/components/CardEvent.vue @@ -3,7 +3,10 @@ SPDX-License-Identifier: AGPL-3.0-or-later -->