feat(i18n): add internationalization support with English and Spanish translations
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

feat(kiosk): implement kiosk report submission functionality with offline support

test(outbox): add tests for outbox functionality including report submission and error handling

feat(outbox): create outbox management for queued reports with IndexedDB persistence

feat(uuid): add UUID generation utility for client IDs

feat(qr): implement QR code generation for reporting links by site and area

feat(kiosk): create Kiosk page for reporting incidents without an account
This commit is contained in:
jayjennings committed 2026-10-06 11:38:40 -07:00
1 parent 3f6dff5f24
commit 3e964d1176
64 files changed
+10056 -267

No files matched your search

@@ -0,0 +1,24 @@
CREATE TYPE "public"."report_channel" AS ENUM('web', 'kiosk');--> statement-breakpoint
CREATE TABLE "kiosks" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"name" text NOT NULL,
"token" text NOT NULL,
"site_id" uuid,
"created_by_id" uuid,
"last_used_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "reporter_name" text;--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "channel" "report_channel" DEFAULT 'web' NOT NULL;--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "kiosk_id" uuid;--> statement-breakpoint
ALTER TABLE "incidents" ADD COLUMN "client_id" uuid;--> statement-breakpoint
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_site_id_sites_id_fk" FOREIGN KEY ("site_id") REFERENCES "public"."sites"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "kiosks" ADD CONSTRAINT "kiosks_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "kiosks_token_unique" ON "kiosks" USING btree ("token");--> statement-breakpoint
CREATE INDEX "kiosks_organization_idx" ON "kiosks" USING btree ("organization_id");--> statement-breakpoint
ALTER TABLE "incidents" ADD CONSTRAINT "incidents_kiosk_id_kiosks_id_fk" FOREIGN KEY ("kiosk_id") REFERENCES "public"."kiosks"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "incidents_organization_client_id_unique" ON "incidents" USING btree ("organization_id","client_id");
File diff suppressed because it is too large. Load diff
+7
View File
@@ -15,6 +15,13 @@
"when": 1791309275942,
"tag": "0001_workflow_and_email",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1791310953907,
"tag": "0002_kiosks_and_offline",
"breakpoints": true
}
]
}
+31
View File
@@ -13,6 +13,7 @@ export const rootCauseCategoryEnum = pgEnum('root_cause_category', vocab.rootCau
export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
export const reportChannelEnum = pgEnum('report_channel', ['web', 'kiosk'])
const id = () => uuid().primaryKey().defaultRandom()
const timestamptz = () => timestamp({ withTimezone: true })
@@ -115,6 +116,29 @@ export const areas = pgTable(
(t) => [index('areas_site_idx').on(t.siteId), index('areas_parent_idx').on(t.parentId)],
)
/**
* Reporting links let people without accounts file reports: on a shared kiosk tablet, or by scanning
* a QR code. The token only allows creating reports, so it is stored as-is to let admins reprint QR codes.
*/
export const kiosks = pgTable(
'kiosks',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
name: text().notNull(),
token: text().notNull(),
/** Limits the link to one site; null allows any. */
siteId: uuid().references(() => sites.id, { onDelete: 'set null' }),
createdById: uuid().references(() => users.id, { onDelete: 'set null' }),
lastUsedAt: timestamptz(),
revokedAt: timestamptz(),
createdAt: createdAt(),
},
(t) => [uniqueIndex('kiosks_token_unique').on(t.token), index('kiosks_organization_idx').on(t.organizationId)],
)
/** Per-organization counter for gap-free, human-friendly incident numbers. */
export const incidentCounters = pgTable('incident_counters', {
organizationId: uuid()
@@ -146,12 +170,19 @@ export const incidents = pgTable(
/** Null when reported anonymously. */
reporterId: uuid().references(() => users.id, { onDelete: 'set null' }),
anonymous: boolean().notNull().default(false),
/** Name typed by someone reporting without an account (kiosk/QR). */
reporterName: text(),
channel: reportChannelEnum().notNull().default('web'),
kioskId: uuid().references(() => kiosks.id, { onDelete: 'set null' }),
/** Generated on the reporting device so retries after a lost response don't create duplicates. */
clientId: uuid(),
closedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [
uniqueIndex('incidents_organization_number_unique').on(t.organizationId, t.number),
uniqueIndex('incidents_organization_client_id_unique').on(t.organizationId, t.clientId),
index('incidents_organization_occurred_idx').on(t.organizationId, t.occurredAt),
index('incidents_organization_status_idx').on(t.organizationId, t.status),
index('incidents_reporter_idx').on(t.reporterId),