Refactor API types and sync logic
Rename and refactor the types passed over the API to be based on an entity that's either living or a tombstone. A living entity has a deleted property that's either undefined or false, while a tombstone has a deleted property set to true. All entities have a numeric id and an updatedAt timestamp. To sync entities, an array of replacements are passed around. Living entities are replaced with tombstones when they're deleted. And tombstones are replaced with living entities when restored.
This commit is contained in:
parent
251e83f640
commit
fe06d0d6bd
36 changed files with 1242 additions and 834 deletions
|
@ -1,39 +1,28 @@
|
|||
import { Account } from "~/shared/types/account";
|
||||
import { readAccounts, writeAccounts } from "~/server/database";
|
||||
import { DateTime } from "luxon";
|
||||
import { apiAccountPatchSchema } from "~/shared/types/api";
|
||||
import { z } from "zod/v4-mini";
|
||||
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireServerSession(event);
|
||||
const body: Pick<Account, "interestedIds" | "timezone"> = await readBody(event);
|
||||
if (
|
||||
body.interestedIds !== undefined
|
||||
&& (
|
||||
!(body.interestedIds instanceof Array)
|
||||
|| !body.interestedIds.every(id => typeof id === "string")
|
||||
)
|
||||
) {
|
||||
const { success, error, data: patch } = apiAccountPatchSchema.safeParse(await readBody(event));
|
||||
if (!success) {
|
||||
throw createError({
|
||||
status: 400,
|
||||
message: "Invalid interestedIds",
|
||||
statusText: "Bad Request",
|
||||
message: z.prettifyError(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (body.timezone !== undefined) {
|
||||
if (typeof body.timezone !== "string") {
|
||||
if (patch.timezone?.length) {
|
||||
const zonedTime = DateTime.local({ locale: "en-US" }).setZone(patch.timezone);
|
||||
if (!zonedTime.isValid) {
|
||||
throw createError({
|
||||
status: 400,
|
||||
message: "Invalid timezone",
|
||||
message: "Invalid timezone: " + zonedTime.invalidExplanation,
|
||||
});
|
||||
}
|
||||
if (body.timezone.length) {
|
||||
const zonedTime = DateTime.local({ locale: "en-US" }).setZone(body.timezone);
|
||||
if (!zonedTime.isValid) {
|
||||
throw createError({
|
||||
status: 400,
|
||||
message: "Invalid timezone: " + zonedTime.invalidExplanation,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const accounts = await readAccounts();
|
||||
|
@ -42,16 +31,23 @@ export default defineEventHandler(async (event) => {
|
|||
throw Error("Account does not exist");
|
||||
}
|
||||
|
||||
if (body.interestedIds !== undefined) {
|
||||
if (body.interestedIds.length) {
|
||||
sessionAccount.interestedIds = body.interestedIds;
|
||||
if (patch.interestedEventIds !== undefined) {
|
||||
if (patch.interestedEventIds.length) {
|
||||
sessionAccount.interestedEventIds = patch.interestedEventIds;
|
||||
} else {
|
||||
delete sessionAccount.interestedIds;
|
||||
delete sessionAccount.interestedEventIds;
|
||||
}
|
||||
}
|
||||
if (body.timezone !== undefined) {
|
||||
if (body.timezone)
|
||||
sessionAccount.timezone = body.timezone;
|
||||
if (patch.interestedEventSlotIds !== undefined) {
|
||||
if (patch.interestedEventSlotIds.length) {
|
||||
sessionAccount.interestedEventSlotIds = patch.interestedEventSlotIds;
|
||||
} else {
|
||||
delete sessionAccount.interestedEventSlotIds;
|
||||
}
|
||||
}
|
||||
if (patch.timezone !== undefined) {
|
||||
if (patch.timezone)
|
||||
sessionAccount.timezone = patch.timezone;
|
||||
else
|
||||
delete sessionAccount.timezone;
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { readAccounts, writeAccounts, nextAccountId } from "~/server/database";
|
||||
import { Account } from "~/shared/types/account";
|
||||
import type { ApiAccount } from "~/shared/types/api";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
let session = await getServerSession(event);
|
||||
|
@ -14,7 +14,7 @@ export default defineEventHandler(async (event) => {
|
|||
const name = formData.get("name");
|
||||
|
||||
const accounts = await readAccounts();
|
||||
let account: Account;
|
||||
let account: ApiAccount;
|
||||
if (typeof name === "string") {
|
||||
if (name === "") {
|
||||
throw createError({
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { readAccounts, readSubscriptions } from "~/server/database";
|
||||
import { AccountSession } from "~/shared/types/account";
|
||||
import type { ApiSession } from "~/shared/types/api";
|
||||
|
||||
export default defineEventHandler(async (event): Promise<AccountSession | undefined> => {
|
||||
export default defineEventHandler(async (event): Promise<ApiSession | undefined> => {
|
||||
const session = await getServerSession(event);
|
||||
if (!session)
|
||||
return;
|
||||
|
|
|
@ -1,41 +1,8 @@
|
|||
import type { SchedulePatch } from "~/shared/types/schedule";
|
||||
import { z } from "zod/v4-mini";
|
||||
import { readAccounts, readSchedule, writeSchedule } from "~/server/database";
|
||||
import { broadcastUpdate } from "~/server/streams";
|
||||
import { applyChangeArray } from "~/shared/utils/changes";
|
||||
|
||||
function isChange(change: unknown) {
|
||||
return (
|
||||
typeof change === "object"
|
||||
&& change !== null
|
||||
&& "op" in change
|
||||
&& (
|
||||
change.op === "set" || change.op === "del"
|
||||
)
|
||||
&& "data" in change
|
||||
&& typeof change.data === "object"
|
||||
&& change.data !== null
|
||||
&& "id" in change.data
|
||||
&& typeof change.data.id === "string"
|
||||
)
|
||||
}
|
||||
|
||||
function isChangeArray(data: unknown) {
|
||||
return data instanceof Array && data.every(item => isChange(item));
|
||||
}
|
||||
|
||||
function isPatch(data: unknown): SchedulePatch {
|
||||
if (
|
||||
typeof data !== "object"
|
||||
|| data === null
|
||||
|| data instanceof Array
|
||||
|| "locations" in data && !isChangeArray(data.locations)
|
||||
|| "events" in data && !isChangeArray(data.events)
|
||||
|| "roles" in data && !isChangeArray(data.roles)
|
||||
|| "rota" in data && !isChangeArray(data.rota)
|
||||
)
|
||||
throw new Error("Invalid patch data")
|
||||
return data // TODO: Actually validate the whole structure with e.g ajv or zod
|
||||
}
|
||||
import { broadcastEvent } from "~/server/streams";
|
||||
import { apiScheduleSchema } from "~/shared/types/api";
|
||||
import { applyUpdatesToArray } from "~/shared/utils/update";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireServerSession(event);
|
||||
|
@ -53,22 +20,43 @@ export default defineEventHandler(async (event) => {
|
|||
});
|
||||
}
|
||||
|
||||
const { success, error, data: update } = apiScheduleSchema.safeParse(await readBody(event));
|
||||
if (!success) {
|
||||
throw createError({
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
message: z.prettifyError(error),
|
||||
});
|
||||
}
|
||||
|
||||
if (update.deleted) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Not implemented",
|
||||
});
|
||||
}
|
||||
|
||||
const schedule = await readSchedule();
|
||||
const patch = await readValidatedBody(event, isPatch);
|
||||
|
||||
if (schedule.deleted) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Not implemented",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate edit restrictions for crew
|
||||
if (account.type === "crew") {
|
||||
if (patch.locations?.length) {
|
||||
if (update.locations?.length) {
|
||||
throw createError({
|
||||
status: 403,
|
||||
statusMessage: "Forbidden",
|
||||
message: "Only admin accounts can edit locations.",
|
||||
});
|
||||
}
|
||||
for (const event of patch.events ?? []) {
|
||||
const id = event.op === "set" ? event.data.id : event.id;
|
||||
const original = schedule.events.find(e => e.id === id);
|
||||
if (original && !original.crew) {
|
||||
for (const event of update.events ?? []) {
|
||||
const original = schedule.events?.find(e => e.id === event.id);
|
||||
if (original && !original.deleted && !original.crew) {
|
||||
throw createError({
|
||||
status: 403,
|
||||
statusMessage: "Forbidden",
|
||||
|
@ -78,11 +66,30 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (patch.events) applyChangeArray(patch.events, schedule.events);
|
||||
if (patch.locations) applyChangeArray(patch.locations, schedule.locations);
|
||||
if (patch.roles) applyChangeArray(patch.roles, schedule.roles = schedule.roles ?? []);
|
||||
if (patch.rota) applyChangeArray(patch.rota, schedule.rota = schedule.rota ?? []);
|
||||
// Update schedule
|
||||
const updatedFrom = schedule.updatedAt;
|
||||
update.updatedAt = new Date().toISOString();
|
||||
if (update.events) {
|
||||
for (const event of update.events) event.updatedAt = update.updatedAt;
|
||||
applyUpdatesToArray(update.events, schedule.events = schedule.events ?? []);
|
||||
}
|
||||
if (update.locations) {
|
||||
for (const location of update.locations) location.updatedAt = update.updatedAt;
|
||||
applyUpdatesToArray(update.locations, schedule.locations = schedule.locations ?? []);
|
||||
}
|
||||
if (update.roles) {
|
||||
for (const role of update.roles) role.updatedAt = update.updatedAt;
|
||||
applyUpdatesToArray(update.roles, schedule.roles = schedule.roles ?? []);
|
||||
}
|
||||
if (update.shifts) {
|
||||
for (const shift of update.shifts) shift.updatedAt = update.updatedAt;
|
||||
applyUpdatesToArray(update.shifts, schedule.shifts = schedule.shifts ?? []);
|
||||
}
|
||||
|
||||
await writeSchedule(schedule);
|
||||
await broadcastUpdate(schedule);
|
||||
await broadcastEvent({
|
||||
type: "schedule-update",
|
||||
updatedFrom,
|
||||
data: update,
|
||||
});
|
||||
})
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
import { readAccounts, readSchedule } from "~/server/database";
|
||||
import { Account } from "~/shared/types/account";
|
||||
import type { ApiAccount } from "~/shared/types/api";
|
||||
import { canSeeCrew } from "../utils/schedule";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await getServerSession(event);
|
||||
let account: Account | undefined;
|
||||
let account: ApiAccount | undefined;
|
||||
if (session) {
|
||||
const accounts = await readAccounts()
|
||||
account = accounts.find(account => account.id === session.accountId);
|
||||
}
|
||||
const schedule = await readSchedule();
|
||||
return canSeeCrew(account?.type) ? schedule : filterSchedule(schedule);
|
||||
})
|
||||
});
|
||||
|
|
|
@ -1,14 +1,26 @@
|
|||
import { readSubscriptions, writeSubscriptions } from "~/server/database";
|
||||
import { Subscription } from "~/shared/types/account";
|
||||
import { type ApiSubscription, apiSubscriptionSchema } from "~/shared/types/api";
|
||||
import { z } from "zod/v4-mini";
|
||||
|
||||
const subscriptionSchema = z.strictObject({
|
||||
subscription: apiSubscriptionSchema.def.shape.push,
|
||||
});
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = await requireServerSession(event);
|
||||
const body: { subscription: PushSubscriptionJSON } = await readBody(event);
|
||||
const { success, error, data: body } = subscriptionSchema.safeParse(await readBody(event));
|
||||
if (!success) {
|
||||
throw createError({
|
||||
status: 400,
|
||||
statusText: "Bad Request",
|
||||
message: z.prettifyError(error),
|
||||
});
|
||||
}
|
||||
const subscriptions = await readSubscriptions();
|
||||
const existingIndex = subscriptions.findIndex(
|
||||
sub => sub.type === "push" && sub.sessionId === session.id
|
||||
);
|
||||
const subscription: Subscription = {
|
||||
const subscription: ApiSubscription = {
|
||||
type: "push",
|
||||
sessionId: session.id,
|
||||
push: body.subscription
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { Schedule } from "~/shared/types/schedule";
|
||||
import { Account, Subscription } from "~/shared/types/account";
|
||||
import type { ApiAccount, ApiSchedule, ApiSubscription } from "~/shared/types/api";
|
||||
import { generateDemoSchedule, generateDemoAccounts } from "./generate-demo-schedule";
|
||||
|
||||
export interface ServerSession {
|
||||
|
@ -51,12 +50,12 @@ export async function readSchedule() {
|
|||
return readJson(schedulePath, generateDemoSchedule);
|
||||
}
|
||||
|
||||
export async function writeSchedule(schedule: Schedule) {
|
||||
export async function writeSchedule(schedule: ApiSchedule) {
|
||||
await writeFile(schedulePath, JSON.stringify(schedule, undefined, "\t") + "\n", "utf-8");
|
||||
}
|
||||
|
||||
export async function readSubscriptions() {
|
||||
let subscriptions = await readJson<Subscription[]>(subscriptionsPath, []);
|
||||
let subscriptions = await readJson<ApiSubscription[]>(subscriptionsPath, []);
|
||||
if (subscriptions.length && "keys" in subscriptions[0]) {
|
||||
// Discard old format
|
||||
subscriptions = [];
|
||||
|
@ -64,7 +63,7 @@ export async function readSubscriptions() {
|
|||
return subscriptions;
|
||||
}
|
||||
|
||||
export async function writeSubscriptions(subscriptions: Subscription[]) {
|
||||
export async function writeSubscriptions(subscriptions: ApiSubscription[]) {
|
||||
await writeFile(subscriptionsPath, JSON.stringify(subscriptions, undefined, "\t") + "\n", "utf-8");
|
||||
}
|
||||
|
||||
|
@ -81,7 +80,7 @@ export async function readAccounts() {
|
|||
return await readJson(accountsPath, generateDemoAccounts);
|
||||
}
|
||||
|
||||
export async function writeAccounts(accounts: Account[]) {
|
||||
export async function writeAccounts(accounts: ApiAccount[]) {
|
||||
await writeFile(accountsPath, JSON.stringify(accounts, undefined, "\t") + "\n", "utf-8");
|
||||
}
|
||||
|
||||
|
|
|
@ -1,30 +1,41 @@
|
|||
import { Account } from "~/shared/types/account";
|
||||
import { Role, Schedule, Shift, ShiftSlot, TimeSlot } from "~/shared/types/schedule";
|
||||
import type { ApiAccount, ApiSchedule, ApiScheduleEventSlot, ApiScheduleShiftSlot } from "~/shared/types/api";
|
||||
import { toId } from "~/shared/utils/functions";
|
||||
|
||||
const locations = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Stage",
|
||||
description: "Inside the main building."
|
||||
description: "Inside the main building.",
|
||||
updatedAt: "d0 18:21",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Clubhouse",
|
||||
description: "That big red building in the middle of the park."
|
||||
description: "That big red building in the middle of the park.",
|
||||
updatedAt: "d0 18:25",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Summerhouse",
|
||||
description: "Next to the campfire by the lake"
|
||||
description: "Next to the campfire by the lake",
|
||||
updatedAt: "d0 18:22",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Campfire",
|
||||
description: "Next to the big tree by the lake."
|
||||
description: "Next to the big tree by the lake.",
|
||||
updatedAt: "d0 18:41",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Outside",
|
||||
description: "Takes place somewhere outside."
|
||||
description: "Takes place somewhere outside.",
|
||||
updatedAt: "d0 18:37",
|
||||
}
|
||||
]
|
||||
|
||||
let slotId = 1;
|
||||
let eventId = 1;
|
||||
const events = [
|
||||
{
|
||||
name: "Arcade",
|
||||
|
@ -133,17 +144,24 @@ const events = [
|
|||
{ name: "Artist Alley", slots: ["d4 12:00 4h clubhouse"]},
|
||||
{ name: "Teardown Artist Alley", crew: true, slots: ["d4 16:00 1h clubhouse"]},
|
||||
{ name: "Feedback Panel", slots: ["d5 12:00 1h clubhouse"]},
|
||||
];
|
||||
].map(({ slots, ...rest }) => ({
|
||||
...rest,
|
||||
id: eventId++,
|
||||
slots: slots.map(slot => `${slot} ${slotId++}`),
|
||||
}));
|
||||
|
||||
const roles: Role[] = [
|
||||
{ id: "medic", name: "Medic" },
|
||||
{ id: "security", name: "Security" },
|
||||
|
||||
const idMedic = 1;
|
||||
const idSecurity = 2;
|
||||
const roles = [
|
||||
{ id: idMedic, name: "Medic", updatedAt: "d1 12:34" },
|
||||
{ id: idSecurity, name: "Security", updatedAt: "d1 12:39" },
|
||||
]
|
||||
|
||||
const rota = [
|
||||
const shifts = [
|
||||
{
|
||||
name: "Medic Early",
|
||||
role: "medic",
|
||||
roleId: idMedic,
|
||||
slots: [
|
||||
"d1 12:00 4h",
|
||||
"d2 12:00 4h",
|
||||
|
@ -154,7 +172,7 @@ const rota = [
|
|||
},
|
||||
{
|
||||
name: "Medic Late",
|
||||
role: "medic",
|
||||
roleId: idMedic,
|
||||
slots: [
|
||||
"d1 16:00 7h",
|
||||
"d2 16:00 6h",
|
||||
|
@ -164,7 +182,7 @@ const rota = [
|
|||
},
|
||||
{
|
||||
name: "Security Early",
|
||||
role: "security",
|
||||
roleId: idSecurity,
|
||||
slots: [
|
||||
"d1 12:00 6h",
|
||||
"d2 12:00 6h",
|
||||
|
@ -175,7 +193,7 @@ const rota = [
|
|||
},
|
||||
{
|
||||
name: "Security Late",
|
||||
role: "security",
|
||||
roleId: idSecurity,
|
||||
slots: [
|
||||
"d1 18:00 5h",
|
||||
"d2 18:00 4h",
|
||||
|
@ -183,13 +201,17 @@ const rota = [
|
|||
"d4 18:00 5h",
|
||||
],
|
||||
},
|
||||
]
|
||||
].map(({ slots, ...rest }) => ({
|
||||
...rest,
|
||||
id: eventId++,
|
||||
slots: slots.map(slot => `${slot} ${slotId++}`),
|
||||
}));
|
||||
|
||||
function toIso(date: Date) {
|
||||
return date.toISOString().replace(":00.000Z", "Z");
|
||||
}
|
||||
|
||||
function toDates(origin: Date, day: string, start: string, duration: string) {
|
||||
function toDate(origin: Date, day: string, start: string) {
|
||||
const [startHours, startMinutes] = start.split(":").map(time => parseInt(time, 10));
|
||||
const dayNumber = parseInt(day.slice(1));
|
||||
|
||||
|
@ -198,6 +220,11 @@ function toDates(origin: Date, day: string, start: string, duration: string) {
|
|||
startDate.setUTCHours(startDate.getUTCHours() + startHours);
|
||||
startDate.setUTCMinutes(startDate.getUTCMinutes() + startMinutes);
|
||||
|
||||
return startDate;
|
||||
}
|
||||
|
||||
function toDates(origin: Date, day: string, start: string, duration: string) {
|
||||
const startDate = toDate(origin, day, start);
|
||||
const [_, durationHours, durationMinutes] = /(?:(\d+)h)?(?:(\d+)m)?/.exec(duration)!;
|
||||
const durationTotal = parseInt(durationHours ?? "0") * 60 + parseInt(durationMinutes ?? "0")
|
||||
const endDate = new Date(startDate.getTime() + durationTotal * 60e3);
|
||||
|
@ -205,33 +232,35 @@ function toDates(origin: Date, day: string, start: string, duration: string) {
|
|||
return [startDate, endDate];
|
||||
}
|
||||
|
||||
function toSlot(origin: Date, id: string, shorthand: string, index: number, counts: Map<string, number>, idToAssigned: Map<string, number[]>): TimeSlot {
|
||||
const [day, start, duration, location] = shorthand.split(" ");
|
||||
function toSlot(origin: Date, shorthand: string, counts: Map<number, number>, idToAssigned: Map<number, number[]>): ApiScheduleEventSlot {
|
||||
const [day, start, duration, location, idStr] = shorthand.split(" ");
|
||||
const [startDate, endDate] = toDates(origin, day, start, duration);
|
||||
const id = parseInt(idStr, 10);
|
||||
|
||||
return {
|
||||
id: `${id}-${index}`,
|
||||
id,
|
||||
start: toIso(startDate),
|
||||
end: toIso(endDate),
|
||||
locations: [location],
|
||||
assigned: idToAssigned.get(`${id}-${index}`),
|
||||
interested: counts.get(`${id}-${index}`),
|
||||
locationIds: [locations.find(l => toId(l.name) === location)!.id],
|
||||
assigned: idToAssigned.get(id),
|
||||
interested: counts.get(id),
|
||||
};
|
||||
}
|
||||
|
||||
function toShift(origin: Date, id: string, shorthand: string, index: number, idToAssigned: Map<string, number[]>): ShiftSlot {
|
||||
const [day, start, duration] = shorthand.split(" ");
|
||||
function toShift(origin: Date, shorthand: string, idToAssigned: Map<number, number[]>): ApiScheduleShiftSlot {
|
||||
const [day, start, duration, idStr] = shorthand.split(" ");
|
||||
const [startDate, endDate] = toDates(origin, day, start, duration);
|
||||
const id = parseInt(idStr, 10);
|
||||
|
||||
return {
|
||||
id: `${id}-${index}`,
|
||||
id,
|
||||
start: toIso(startDate),
|
||||
end: toIso(endDate),
|
||||
assigned: idToAssigned.get(`${id}-${index}`),
|
||||
assigned: idToAssigned.get(id),
|
||||
};
|
||||
}
|
||||
|
||||
export function generateDemoSchedule(): Schedule {
|
||||
export function generateDemoSchedule(): ApiSchedule {
|
||||
const origin = new Date();
|
||||
const utcOffset = 1;
|
||||
origin.setUTCDate(origin.getUTCDate() - origin.getUTCDay() + 1); // Go to Monday
|
||||
|
@ -240,101 +269,98 @@ export function generateDemoSchedule(): Schedule {
|
|||
origin.setUTCSeconds(0);
|
||||
origin.setUTCMilliseconds(0);
|
||||
|
||||
const counts = new Map<string, number>()
|
||||
const eventCounts = new Map<number, number>()
|
||||
const slotCounts = new Map<number, number>()
|
||||
const accounts = generateDemoAccounts();
|
||||
for (const account of accounts) {
|
||||
for (const id of account.interestedIds ?? []) {
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
for (const id of account.interestedEventIds ?? []) {
|
||||
eventCounts.set(id, (eventCounts.get(id) ?? 0) + 1);
|
||||
}
|
||||
for (const id of account.interestedEventSlotIds ?? []) {
|
||||
slotCounts.set(id, (slotCounts.get(id) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
seed = 2;
|
||||
const idToAssigned = new Map<string, number[]>();
|
||||
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
|
||||
const assignedIds: string[] = [];
|
||||
const slotsToAdd = Math.floor(random() * 20);
|
||||
while (assignedIds.length < slotsToAdd) {
|
||||
const event = events[Math.floor(random() * events.length)];
|
||||
const eventId = toId(event.name);
|
||||
if (assignedIds.some(id => id.replace(/-\d+$/, "") === eventId)) {
|
||||
continue;
|
||||
}
|
||||
if (event.slots.length === 1 || random() < 0.8) {
|
||||
for (const index of event.slots.map((_, index) => index)) {
|
||||
assignedIds.push(toId(`${toId(event.name)}-${index}`));
|
||||
function assignSlots(events: { id: number, slots: string[] }[], count: number) {
|
||||
const idToAssigned = new Map<number, number[]>();
|
||||
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
|
||||
const assignedIds = new Set<number>;
|
||||
const usedEvents = new Set<number>;
|
||||
const slotsToAdd = Math.floor(random() * count);
|
||||
while (assignedIds.size < slotsToAdd) {
|
||||
const event = events[Math.floor(random() * events.length)];
|
||||
if (usedEvents.has(event.id)) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
for (const index of event.slots.map((_, index) => index)) {
|
||||
if (random() < 0.5) {
|
||||
assignedIds.push(toId(`${toId(event.name)}-${index}`));
|
||||
if (event.slots.length === 1 || random() < 0.8) {
|
||||
for (const slot of event.slots) {
|
||||
const id = parseInt(slot.split(" ").slice(-1)[0]);
|
||||
assignedIds.add(id);
|
||||
usedEvents.add(event.id);
|
||||
}
|
||||
} else {
|
||||
for (const slot of event.slots) {
|
||||
if (random() < 0.5) {
|
||||
const id = parseInt(slot.split(" ").slice(-1)[0]);
|
||||
assignedIds.add(id);
|
||||
usedEvents.add(event.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of assignedIds) {
|
||||
const assigned = idToAssigned.get(id);
|
||||
if (assigned) {
|
||||
assigned.push(account.id);
|
||||
} else {
|
||||
idToAssigned.set(id, [account.id]);
|
||||
for (const id of assignedIds) {
|
||||
const assigned = idToAssigned.get(id);
|
||||
if (assigned) {
|
||||
assigned.push(account.id);
|
||||
} else {
|
||||
idToAssigned.set(id, [account.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return idToAssigned;
|
||||
}
|
||||
seed = 2;
|
||||
const eventSlotIdToAssigned = assignSlots(events, 20);
|
||||
|
||||
seed = 5;
|
||||
for (const account of accounts.filter(a => a.type === "crew" || a.type === "admin")) {
|
||||
const assignedIds: string[] = [];
|
||||
const slotsToAdd = Math.floor(random() * 3);
|
||||
while (assignedIds.length < slotsToAdd) {
|
||||
const shift = rota[Math.floor(random() * rota.length)];
|
||||
const shiftId = toId(shift.name);
|
||||
if (assignedIds.some(id => id.replace(/-\d+$/, "") === shiftId)) {
|
||||
continue;
|
||||
}
|
||||
if (shift.slots.length === 1 || random() < 0.8) {
|
||||
for (const index of shift.slots.map((_, index) => index)) {
|
||||
assignedIds.push(toId(`${toId(shift.name)}-${index}`));
|
||||
}
|
||||
} else {
|
||||
for (const index of shift.slots.map((_, index) => index)) {
|
||||
if (random() < 0.5) {
|
||||
assignedIds.push(toId(`${toId(shift.name)}-${index}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const id of assignedIds) {
|
||||
const assigned = idToAssigned.get(id);
|
||||
if (assigned) {
|
||||
assigned.push(account.id);
|
||||
} else {
|
||||
idToAssigned.set(id, [account.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const shiftSlotIdToAssigned = assignSlots(shifts, 3);
|
||||
|
||||
return {
|
||||
id: 111,
|
||||
updatedAt: toIso(toDate(origin, "d2", "10:01")),
|
||||
events: events.map(
|
||||
({ name, crew, description, slots }) => ({
|
||||
id: toId(name),
|
||||
({ id, name, crew, description, slots }) => ({
|
||||
id,
|
||||
name,
|
||||
crew,
|
||||
description,
|
||||
interested: counts.get(toId(name)),
|
||||
slots: slots.map((shorthand, index) => toSlot(origin, toId(name), shorthand, index, counts, idToAssigned))
|
||||
interested: eventCounts.get(id),
|
||||
slots: slots.map(shorthand => toSlot(origin, shorthand, slotCounts, eventSlotIdToAssigned)),
|
||||
updatedAt: toIso(toDate(origin, "d0", "15:11")),
|
||||
})
|
||||
),
|
||||
locations: locations.map(
|
||||
({ name, description }) => ({ id: toId(name), name, description })
|
||||
),
|
||||
roles,
|
||||
rota: rota.map(
|
||||
({ name, role, slots }) => ({
|
||||
id: toId(name),
|
||||
({ id, name, description, updatedAt }) => ({
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
slots: slots.map((shorthand, index) => toShift(origin, toId(name), shorthand, index, idToAssigned))
|
||||
description,
|
||||
updatedAt: toIso(toDate(origin, ...(updatedAt.split(" ")) as [string, string])),
|
||||
})
|
||||
),
|
||||
roles: roles.map(
|
||||
({ id, name, updatedAt }) => ({
|
||||
id,
|
||||
name,
|
||||
updatedAt: toIso(toDate(origin, ...(updatedAt.split(" ")) as [string, string])),
|
||||
})
|
||||
),
|
||||
shifts: shifts.map(
|
||||
({ id, name, roleId, slots }) => ({
|
||||
id,
|
||||
name,
|
||||
roleId,
|
||||
slots: slots.map(shorthand => toShift(origin, shorthand, shiftSlotIdToAssigned)),
|
||||
updatedAt: toIso(toDate(origin, "d0", "13:23")),
|
||||
})
|
||||
)
|
||||
};
|
||||
|
@ -364,9 +390,9 @@ function random() {
|
|||
return (seed = (a * seed + c) % m | 0) / 2 ** 31;
|
||||
}
|
||||
|
||||
export function generateDemoAccounts(): Account[] {
|
||||
export function generateDemoAccounts(): ApiAccount[] {
|
||||
seed = 1;
|
||||
const accounts: Account[] = [];
|
||||
const accounts: ApiAccount[] = [];
|
||||
|
||||
for (const name of names) {
|
||||
accounts.push({
|
||||
|
@ -378,38 +404,45 @@ export function generateDemoAccounts(): Account[] {
|
|||
|
||||
seed = 1;
|
||||
// These have a much higher probability of being in someone's interested list.
|
||||
const desiredEvent = ["opening", "closing", "fursuit-games"];
|
||||
const desiredEvent = ["opening", "closing", "fursuit-games"].map(
|
||||
id => events.find(e => toId(e.name) === id)!.id
|
||||
);
|
||||
const nonCrewEvents = events.filter(event => !event.crew);
|
||||
|
||||
for (const account of accounts) {
|
||||
const interestedIds: string[] = [];
|
||||
const interestedEventIds = new Set<number>;
|
||||
const interestedSlotIds = new Set<number>;
|
||||
const usedEvents = new Set<number>;
|
||||
for (const id of desiredEvent) {
|
||||
if (random() < 0.5) {
|
||||
interestedIds.push(id);
|
||||
interestedEventIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const eventsToAdd = Math.floor(random() * 10);
|
||||
while (interestedIds.length < eventsToAdd) {
|
||||
while (interestedEventIds.size + interestedSlotIds.size < eventsToAdd) {
|
||||
const event = nonCrewEvents[Math.floor(random() * nonCrewEvents.length)];
|
||||
const eventId = toId(event.name);
|
||||
if (interestedIds.some(id => id.replace(/-\d+$/, "") === eventId)) {
|
||||
if (usedEvents.has(event.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.slots.length === 1 || random() < 0.8) {
|
||||
interestedIds.push(toId(event.name))
|
||||
interestedEventIds.add(event.id)
|
||||
} else {
|
||||
for (const index of event.slots.map((_, index) => index)) {
|
||||
for (const slot of event.slots) {
|
||||
if (random() < 0.5) {
|
||||
interestedIds.push(toId(`${toId(event.name)}-${index}`));
|
||||
const id = parseInt(slot.split(" ")[4], 10);
|
||||
interestedSlotIds.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (interestedIds.length) {
|
||||
account.interestedIds = interestedIds;
|
||||
if (interestedEventIds.size) {
|
||||
account.interestedEventIds = [...interestedEventIds];
|
||||
}
|
||||
if (interestedSlotIds.size) {
|
||||
account.interestedEventSlotIds = [...interestedSlotIds];
|
||||
}
|
||||
}
|
||||
return accounts;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Schedule } from "~/shared/types/schedule"
|
||||
import { readAccounts } from "~/server/database";
|
||||
import { canSeeCrew } from "./utils/schedule";
|
||||
import type { ApiAccount, ApiEvent } from "~/shared/types/api";
|
||||
|
||||
function sendMessage(
|
||||
stream: WritableStream<string>,
|
||||
|
@ -65,22 +65,58 @@ export function cancelSessionStreams(sessionId: number) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function broadcastUpdate(schedule: Schedule) {
|
||||
const encodeEventCache = new WeakMap<ApiEvent, Map<ApiAccount["type"] | undefined, string>>();
|
||||
function encodeEvent(event: ApiEvent, accountType: ApiAccount["type"] | undefined) {
|
||||
const cache = encodeEventCache.get(event);
|
||||
const cacheEntry = cache?.get(accountType);
|
||||
if (cacheEntry) {
|
||||
return cacheEntry;
|
||||
}
|
||||
|
||||
let data: string;
|
||||
if (event.type === "schedule-update") {
|
||||
if (!canSeeCrew(accountType)) {
|
||||
event = {
|
||||
type: event.type,
|
||||
updatedFrom: event.updatedFrom,
|
||||
data: filterSchedule(event.data),
|
||||
};
|
||||
}
|
||||
data = JSON.stringify(event);
|
||||
} else {
|
||||
throw Error(`encodeEvent cannot encode ${event.type} event`);
|
||||
}
|
||||
|
||||
if (cache) {
|
||||
cache.set(accountType, data);
|
||||
} else {
|
||||
encodeEventCache.set(event, new Map([[accountType, data]]));
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function broadcastEvent(event: ApiEvent) {
|
||||
const id = Date.now();
|
||||
console.log(`broadcasting update to ${streams.size} clients`);
|
||||
if (!streams.size) {
|
||||
return;
|
||||
}
|
||||
const accounts = await readAccounts();
|
||||
const filteredSchedule = filterSchedule(schedule);
|
||||
for (const [stream, streamData] of streams) {
|
||||
let accountType: string | undefined;
|
||||
if (streamData.accountId !== undefined) {
|
||||
accountType = accounts.find(a => a.id === streamData.accountId)?.type
|
||||
// Account events are specially handled and only sent to the account 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 accountType: ApiAccount["type"] | undefined;
|
||||
if (streamData.accountId !== undefined) {
|
||||
accountType = accounts.find(a => a.id === streamData.accountId)?.type
|
||||
}
|
||||
const data = encodeEvent(event, accountType)
|
||||
sendMessage(stream, `id: ${id}\nevent: update\ndata: ${data}\n\n`);
|
||||
}
|
||||
const data = JSON.stringify(canSeeCrew(accountType) ? schedule : filteredSchedule);
|
||||
const message = `id: ${id}\nevent: update\ndata: ${data}\n\n`
|
||||
sendMessage(stream, message);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,24 +1,61 @@
|
|||
import { Account } from '~/shared/types/account';
|
||||
import { Schedule } from '~/shared/types/schedule';
|
||||
import { readSchedule, writeSchedule } from '~/server/database';
|
||||
import { broadcastUpdate } from '~/server/streams';
|
||||
import { broadcastEvent } from '~/server/streams';
|
||||
import type { ApiAccount, ApiSchedule } from '~/shared/types/api';
|
||||
|
||||
export async function updateScheduleInterestedCounts(accounts: Account[]) {
|
||||
const counts = new Map();
|
||||
for (const account of accounts)
|
||||
if (account.interestedIds)
|
||||
for (const id of account.interestedIds)
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
export async function updateScheduleInterestedCounts(accounts: ApiAccount[]) {
|
||||
const eventCounts = new Map<number, number>();
|
||||
const eventSlotCounts = new Map<number, number>();
|
||||
for (const account of accounts) {
|
||||
if (account.interestedEventIds)
|
||||
for (const id of account.interestedEventIds)
|
||||
eventCounts.set(id, (eventCounts.get(id) ?? 0) + 1);
|
||||
if (account.interestedEventSlotIds)
|
||||
for (const id of account.interestedEventSlotIds)
|
||||
eventSlotCounts.set(id, (eventSlotCounts.get(id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const schedule = await readSchedule();
|
||||
for (const event of schedule.events) {
|
||||
event.interested = counts.get(event.id);
|
||||
if (schedule.deleted) {
|
||||
throw new Error("Deleted schedule not implemented");
|
||||
}
|
||||
const update: ApiSchedule = {
|
||||
id: schedule.id,
|
||||
updatedAt: new Date().toISOString(),
|
||||
events: [],
|
||||
};
|
||||
const updatedFrom = schedule.updatedAt;
|
||||
for (const event of schedule.events ?? []) {
|
||||
let modified = false;
|
||||
if (event.deleted)
|
||||
continue;
|
||||
|
||||
let count = eventCounts.get(event.id);
|
||||
if (count !== event.interested) {
|
||||
event.interested = eventCounts.get(event.id);
|
||||
modified = true;
|
||||
}
|
||||
for (const slot of event.slots) {
|
||||
slot.interested = counts.get(slot.id);
|
||||
let slotCount = eventSlotCounts.get(slot.id);
|
||||
if (slotCount !== slot.interested) {
|
||||
slot.interested = slotCount;
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
if (modified) {
|
||||
event.updatedAt = update.updatedAt;
|
||||
update.events!.push(event);
|
||||
}
|
||||
}
|
||||
if (!update.events!.length) {
|
||||
return; // No changes
|
||||
}
|
||||
schedule.updatedAt = updatedFrom;
|
||||
await writeSchedule(schedule);
|
||||
await broadcastUpdate(schedule);
|
||||
await broadcastEvent({
|
||||
type: "schedule-update",
|
||||
updatedFrom,
|
||||
data: update,
|
||||
});
|
||||
}
|
||||
|
||||
export function canSeeCrew(accountType: string | undefined) {
|
||||
|
@ -26,17 +63,28 @@ export function canSeeCrew(accountType: string | undefined) {
|
|||
}
|
||||
|
||||
/** Filters out crew visible only parts of schedule */
|
||||
export function filterSchedule(schedule: Schedule): Schedule {
|
||||
export function filterSchedule(schedule: ApiSchedule): ApiSchedule {
|
||||
if (schedule.deleted) {
|
||||
return schedule;
|
||||
}
|
||||
return {
|
||||
id: schedule.id,
|
||||
updatedAt: schedule.updatedAt,
|
||||
locations: schedule.locations,
|
||||
events: schedule.events
|
||||
.filter(event => !event.crew)
|
||||
.map(event => ({
|
||||
...event,
|
||||
slots: event.slots.map(slot => ({
|
||||
...slot,
|
||||
assigned: undefined,
|
||||
})),
|
||||
events: (schedule.events ?? [])
|
||||
.map(event => (
|
||||
event.deleted
|
||||
? event
|
||||
: event.crew
|
||||
// Pretend crew events are deleted.
|
||||
? { id: event.id, deleted: true, updatedAt: event.updatedAt }
|
||||
: {
|
||||
...event,
|
||||
slots: event.slots.map(slot => ({
|
||||
...slot,
|
||||
assigned: undefined,
|
||||
}
|
||||
)),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import type { H3Event } from "h3";
|
||||
import { nextSessionId, readSessions, readSubscriptions, ServerSession, writeSessions, writeSubscriptions } from "~/server/database";
|
||||
import { nextSessionId, readSessions, readSubscriptions, type ServerSession, writeSessions, writeSubscriptions } from "~/server/database";
|
||||
|
||||
const oneYearSeconds = 365 * 24 * 60 * 60;
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue