diff --git a/shared/types/api.ts b/shared/types/api.ts index ce51f71..650d5cb 100644 --- a/shared/types/api.ts +++ b/shared/types/api.ts @@ -1,9 +1,17 @@ import { z } from "zod/v4-mini"; import { defineEntity, idSchema, type Id } from "~/shared/types/common"; +export const apiUserTypeSchema = z.union([ + z.literal("anonymous"), + z.literal("regular"), + z.literal("crew"), + z.literal("admin"), +]) +export type ApiUserType = z.infer; + export interface ApiAccount { id: Id, - type: "anonymous" | "regular" | "crew" | "admin", + type: ApiUserType, /** Name of the account. Not present on anonymous accounts */ name?: string, interestedEventIds?: number[], @@ -96,6 +104,12 @@ export const apiScheduleSchema = defineEntity({ }); export type ApiSchedule = z.infer; +export const apiUserSchema = defineEntity({ + type: apiUserTypeSchema, + name: z.optional(z.string()), +}); +export type ApiUser = z.infer; + export interface ApiAccountUpdate { type: "account-update", data: ApiAccount, diff --git a/utils/client-user.ts b/utils/client-user.ts new file mode 100644 index 0000000..8e90de3 --- /dev/null +++ b/utils/client-user.ts @@ -0,0 +1,168 @@ +import type { ApiUser, ApiUserType } from "~/shared/types/api"; +import type { Entity, EntityLiving, Id, Living } from "~/shared/types/common"; +import { DateTime, Zone } from "~/shared/utils/luxon"; + +export abstract class ClientEntityNew { + /** + Millisecond offset used to indicate this is a new entitity. + */ + static newEntityMillis = -1; + /** + Timestamp of the entity received from server. If this is + a new entity this will have a millisecond offset equal to + {@link ClientEntityNew.newEntityMillis}. + */ + serverUpdatedAt: DateTime; + /** + True if the server has deleted this entity, but the client + is holding on to it in order to resolve an edit conflcit + */ + serverDeleted: boolean; + + constructor( + /** + Server supplied id of this entity. Each kind of entity has its own namespace of ids. + */ + public readonly id: Id, + /** + Server's timestamp of this entity at the time it was modified. If the entity + is unmodified this will track {@link serverUpdatedAt}. If this is a new entity + it'll have a millesecond offset equal to {@link ClientEntityNew.newEntityMillis}. + */ + public updatedAt: DateTime, + /** + Flag indicating the client intends to delete this entity. + */ + public deleted: boolean, + ) { + this.serverUpdatedAt = updatedAt; + this.serverDeleted = deleted; + } + + /** + True if this entity does not yet exist on the server. + */ + isNew() { + return this.serverUpdatedAt.toMillis() === ClientEntityNew.newEntityMillis; + } + + /** + True if both the server and the client have modified this entity + independently of each other. + */ + isConflict() { + return this.serverUpdatedAt.toMillis() !== this.updatedAt.toMillis(); + } + + /** + True if this entity has been modified on the client. + */ + isModified() { + return ( + this.isNew() + || this.deleted + || this.serverDeleted + ); + } + + /** + Discard any client side modifications to this entity. Not allowed if + {@link serverDeleted} is true or this is a new entity. + */ + abstract discard(): void + + /** + Apply an update delivered from the API to this entity. + */ + abstract apiUpdate(api: EntityLiving, opts: { zone: Zone, locale: string }): void + + /** + Serialise this entity to the API format. Not allowed if {@link deleted} is true. + */ + abstract toApi(): Entity +} + +export class ClientUser extends ClientEntityNew { + static type = "user"; + serverName: string | undefined; + serverType: ApiUserType + + constructor( + id: Id, + updatedAt: DateTime, + deleted: boolean, + public name: string | undefined, + public type: ApiUserType, + ) { + super(id, updatedAt, deleted); + this.serverName = name; + this.serverType = type; + } + + override isModified() { + return ( + super.isModified() + || this.name !== this.serverName + || this.type !== this.serverType + ); + } + + override discard() { + if (this.isNew()) { + throw new Error("ClientUser.discard: Cannot discard new entity.") + } + this.updatedAt = this.serverUpdatedAt; + this.name = this.serverName; + this.type = this.serverType; + } + + static create( + id: Id, + name: string | undefined, + type: ApiUserType, + opts: { zone: Zone, locale: string }, + ) { + return new this( + id, + DateTime.fromMillis(ClientEntityNew.newEntityMillis, opts), + false, + name, + type, + ) + } + + static fromApi(api: Living, opts: { zone: Zone, locale: string }) { + try { + return new this( + api.id, + DateTime.fromISO(api.updatedAt, opts), + false, + api.name, + api.type, + ); + } catch (err) { + console.error(api); + throw err; + } + } + + override apiUpdate(api: Living, opts: { zone: Zone, locale: string }) { + const wasModified = this.isModified(); + this.serverUpdatedAt = DateTime.fromISO(api.updatedAt, opts); + this.serverDeleted = false; + this.serverName = api.name; + this.serverType = api.type; + if (!wasModified || !this.isModified()) { + this.discard(); + } + } + + toApi(): ApiUser { + return { + id: this.id, + updatedAt: toIso(this.updatedAt), + name: this.name, + type: this.type, + }; + } +}