Compare commits

...
44 Commits
Author SHA1 Message Date
NeilClack c19c13b192 Document dynamic audio and add a schedule response example 2026-09-11 18:45:59 -05:00
NeilClack ec826093eb Expose dynamic audio on the v2 schedule API 2026-09-11 18:41:57 -05:00
NeilClack 96a1342c10 Add Enable Dynamic Audio option to schedule blocks 2026-09-11 18:41:53 -05:00
NeilClack 468a65a688 Add dynamic audio flag to schedule blocks 2026-09-11 18:41:46 -05:00
NeilClack e934a98f00 Use a 12-hour clock with AM/PM selects in the block modal 2026-09-11 18:30:26 -05:00
NeilClack 15bb51582b Move Cancel left and rename Back to Accept in the block modal 2026-09-11 18:30:08 -05:00
NeilClack 46d7cf66b8 Render overnight blocks across midnight in the day view 2026-09-11 18:24:54 -05:00
NeilClack 177b7bb13a Flag overnight blocks in the v2 schedule API 2026-09-11 18:24:51 -05:00
NeilClack 750a9286c9 Support overnight blocks in schedule validation 2026-09-11 18:24:48 -05:00
NeilClack c177e7953d Document the v2 schedule endpoint 2026-09-11 18:16:08 -05:00
NeilClack 5f4116b8e8 Add schedule page with block editor and proclamation browser 2026-09-11 18:16:06 -05:00
NeilClack 545fb22910 Add v2 schedule API endpoint 2026-09-11 18:16:04 -05:00
NeilClack 4922e941ae Add schedule validation and service layer 2026-09-11 18:16:00 -05:00
NeilClack e6b492a335 Add schedule data model and migration 2026-09-11 18:15:57 -05:00
NeilClack 960673bbc6 Add a site-wide dark mode
Build and Push to Digital Ocean Container Registry / build-and-push (push) Failing after 4s
Build and Push to Digital Ocean Container Registry / migrate (push) Skipped
2026-09-11 12:49:46 -05:00
NeilClack a909db39b1 Delete "API Key" page 2026-09-11 12:44:44 -05:00
NeilClack 020289966d Remove "User Roles" admin page 2026-09-11 12:44:25 -05:00
NeilClack fa614b8c37 Fix staff(+) unable to edit or delete addresses 2026-09-11 12:43:41 -05:00
NeilClack cf8fc7b719 Fix users unable to edit or delete their own addresses 2026-09-11 12:42:35 -05:00
NeilClack 848e646880 Remove the extra page separator in the Login page 2026-09-11 12:41:49 -05:00
NeilClack 98d9b095d9 Document v2 API with OpenAPI schema 2026-09-10 16:19:53 -05:00
NeilClack 2bbee98594 Add dedicated v2 proclamation category/group endpoints and catalog specs 2026-09-10 16:19:53 -05:00
NeilClack 4d929df604 Remove stale admin overhaul and API update docs 2026-09-10 16:19:52 -05:00
NeilClack 10aad07e6d Add admin Audit Log page 2026-09-10 15:10:16 -05:00
NeilClack d16965dab6 Add AuditLog table and Prisma audit extension 2026-09-10 15:10:11 -05:00
NeilClack 7596937f30 Enforce roleId checks on all admin page actions 2026-09-10 14:59:32 -05:00
NeilClack 0cb4b2148a Expose roleId on the Better Auth session user 2026-09-10 14:59:28 -05:00
NeilClack 9de0e2e25a Fix Super admins being blocked from editing proclamations 2026-09-10 14:50:08 -05:00
NeilClack 9cd5397395 Add Resend email template HTML files 2026-09-10 14:35:54 -05:00
NeilClack f6b8cad15e Add subject to password reset email 2026-09-10 14:14:45 -05:00
NeilClack 29d53af8dd Remove unauthenticated /send email endpoint 2026-09-10 14:14:44 -05:00
NeilClack 809ced6d8a Automated signups use the registration-email set-password link 2026-09-10 14:08:56 -05:00
NeilClack 84c905c9ff Add shared registration invite helper and use it for admin-created users 2026-09-10 14:08:51 -05:00
NeilClack ae8d661364 Fix reset-password page to look up the reset token correctly 2026-09-10 13:42:36 -05:00
NeilClack 5ec3018f8b Email a set-password link when an admin creates a user 2026-09-10 13:42:33 -05:00
NeilClack 8a3c1587b4 Fix admin modals getting stuck after a successful submit 2026-09-10 13:33:33 -05:00
NeilClack e60d61e90a Fix admin modals replaying old action results after create or edit 2026-09-10 13:26:54 -05:00
NeilClack f1d80279e9 Record last validation time in v2 license endpoint 2026-09-10 13:18:22 -05:00
NeilClack 4979d955c2 Show active check and last verified date on dashboard license table 2026-09-10 13:18:18 -05:00
NeilClack 0175cab0b4 Add lastValidatedAt to license keys 2026-09-10 13:18:15 -05:00
NeilClack 1aecc51bbd Replace account and license-keys pages with profile page and dashboard 2026-09-10 13:11:41 -05:00
NeilClack f50eb34769 Add createdAt to license keys 2026-09-10 13:11:34 -05:00
NeilClack 4a1777de87 Keep legacy subscription tables as unmanaged instead of dropping them 2026-09-10 11:48:49 -05:00
NeilClack d89fc6837c Add default roleId 4 to new users 2026-09-10 11:48:20 -05:00
134 changed files with 14410 additions and 1975 deletions
+4 -1
View File
@@ -5,5 +5,8 @@ GOOGLE_CLIENT_ID=6
GOOGLE_CLIENT_SECRET=
RESEND_API_KEY=
EMAIL_FROM=
RESEND_TEMPLATE_ID=
# Optional. Resend template for the admin "set your password" invite email.
# Defaults to `registration-email`. Expected variables:
# `target_email_address`, `registration_link`.
RESEND_REGISTRATION_TEMPLATE_ID=
API_V2_ONBOARDING_SECRET=
-2
View File
@@ -45,10 +45,8 @@ CRUD layer has been removed; see `docs/ADMIN_OVERHAUL.md` for the migration note
| -------------------------------- | ------------------------ |
| `/admin/users` | Name, Email, Role |
| `/admin/profiles` | Reference implementation |
| `/admin/user-roles` | |
| `/admin/proclamations` | Super-only content edits |
| `/admin/solfeggio-frequencies` | |
| `/admin/proclamation-categories` | Draggable reorder |
| `/admin/proclamation-groups` | Draggable reorder |
| `/admin/license-keys` | List-only + rotate |
| `/admin/api-keys` | Super-only key rotation |
-185
View File
@@ -1,185 +0,0 @@
# Admin Overhaul & Bug Fix Plan
Status: **IN PROGRESS** — checkboxes are ticked as work is completed and committed.
## Context
The admin section is mid-refactor. The generic `$lib/svelte-admin/*` CRUD layer and
`AdminTable.svelte` were deleted; the **Profiles** page was rebuilt by hand with
`DataTable.svelte`, `AdminModal.svelte`, and route-specific form component(s). The
remaining admin pages still import the deleted modules, so the app does not compile.
This plan:
1. Fixes the reported Profiles-page bugs (stale buttons, unified User/Profile identity,
in-modal status messages, centered modal with backdrop).
2. Rebuilds every other admin page to match the Profiles page look, style, and
functionality — KISS, no clever abstractions.
3. Removes the **Admin dashboard** — the nav links are the navigation.
4. Uses `DataTable` / `AdminModal` / `Toast` as dumb containers; each page owns its
own form logic.
Roles: `SUPER = 1`, `ADMIN = 2`, `STAFF = 3`, `USER = 4`. Admin area requires `roleId <= 3`.
## Decisions Locked In
- **Dashboard**: removed. `/admin` redirects to `/admin/profiles`.
- **Profile email is the identity.** The User dropdown is removed from the Profiles
form. One Email field:
- Linked profile (`userId` set): field shows/edits the User's email; saving
syncs the change to the `User` record.
- Unlinked profile: email lives on the Profile only.
- On save, if an existing `User` matches the entered email, the profile is linked
to it automatically.
- **Users page** is where Users are edited (Name, Email, Role) — not the Profiles page.
- **Status messages live inside modals**, at the top, not in a page-level toast.
- **API Keys**: pending operator review. Applied: compile fix (remove `$lib/svelte-admin/roles`
dependency) + consistent styling. Modal-based rotation flow is deferred (see appendix).
---
## Checklist
### Phase 0 — Documentation & scaffolding
- [x] Document this plan at `docs/ADMIN_OVERHAUL.md` (this file)
- [x] Remove Admin dashboard: `/admin` redirects to `/admin/profiles`
- [x] Delete `(protected)/admin/+page.svelte`
- [x] Rewrite `(protected)/admin/+page.server.ts` as a `redirect(307, '/admin/profiles')`
### Phase 1 — Shared components
- [x] Rewrite `src/lib/components/admin/AdminModal.svelte`
- [x] Fixed overlay, centered panel (`fixed inset-0 z-50`)
- [x] Dark translucent backdrop (`bg-slate-900/50`), click/`Esc` to close
- [x] Panel: `max-w-2xl`, `max-h-[90vh]`, scrollable body
- [x] Header: title, Delete (when `canDelete && recordId`), close `×`
- [x] Extend `src/lib/components/DataTable.svelte`
- [x] Optional `copyableFields` → copy button per matching cell (`stopPropagation`)
- [x] Optional `draggable` + `onReorder` → drag-handle reorder column
- [x] Restyle `src/lib/components/admin/Toast.svelte` (fixed, dark chip, countdown/dismiss)
### Phase 2 — Profiles page (reference implementation)
- [x] `+page.server.ts`
- [x] Remove user-options relation data / `updateUser` action
- [x] New schema: `email` (required), `firstName`, `lastName`, `phoneNumber`
- [x] Upsert: create links to matching `User` by email; update syncs linked `User.email`;
guard `P2002` with friendly message
- [x] `ProfileFields.svelte`
- [x] Single Email field (bound from `user.email ?? record.email`); remove User select
- [x] `CreateProfileModal.svelte`
- [x] Fix skip-first `lastForm` effect bug
- [x] In-modal success banner ("Profile created") then auto-close
- [x] In-modal error banner + field errors
- [x] `EditProfileModal.svelte`
- [x] Fix skip-first `lastForm` effect bug
- [x] Reset sub-state per record (addresses, toggles, errors) on record change
- [x] Remove the "Edit User" / "Save User" section
- [x] In-modal success/error banners; keep open on successful edit
- [x] Clear & close Add-Address form on success; append returned address live
- [x] `+page.svelte`
- [x] Email column shows `user.email ?? email`; remove `users` prop plumbing
- [x] `{#key editRecord?.id}` so the edit modal remounts per record
- [x] Remove page-level `Toast` for modal feedback
- [x] New spec: `profiles/+page.server.spec.ts`
- [x] Create links by matched email; unlinked stores email only
- [x] Update syncs linked `User.email`; rejects email owned by another user
- [x] Validation failures and `P2002` handling
### Phase 3 — Users page
- [x] `+page.server.ts` — load users + role options (role-filtered for non-super);
`upsert` name/email/roleId; `delete`; `P2002` guard
- [x] `+page.svelte` — `DataTable` (Name, Email, Role, Created) + edit `AdminModal`
(Name, Email, Role select, Delete)
- [x] Replace `UserForm.svelte` usage (or convert to inline form) — no `svelte-admin`
### Phase 4 — User Roles page
- [x] `+page.server.ts` — direct `prisma.userRole` load/upsert/delete (P2003 guard)
- [x] `+page.svelte` — `DataTable` (Id, Name) + modal
### Phase 5 — Solfeggio Frequencies page
- [x] `+page.server.ts` — direct `prisma.solfeggioFrequency` load/upsert/delete (P2003 guard)
- [x] `+page.svelte` — `DataTable` (Name, Value) + modal
### Phase 6 — Proclamation Categories page
- [x] `+page.server.ts` — direct `prisma.proclamationCategory` load/upsert/delete/reorder
(P2002/P2003 guards, maxSort for new rows)
- [x] `+page.svelte` — draggable `DataTable` + modal (Name), reorder via fetch + invalidateAll
### Phase 7 — Proclamation Groups page
- [x] `+page.server.ts` — direct `prisma.proclamationGroup` load/upsert/delete/reorder
(P2002/P2003 guards)
- [x] `+page.svelte` — draggable `DataTable` (Name, Sort) + modal
### Phase 8 — License Keys page
- [x] `+page.server.ts` — direct `prisma.licenseKey` load (with user) + `createKey` + `replace`
- [x] `+page.svelte` — `DataTable` with copyable Key column; New-by-email modal; row → Replace modal
- [x] In-modal success/error banners for both flows
### Phase 9 — Proclamations page
- [x] `+page.server.ts` — direct load (strip content for non-super) + upsert (Super only,
P2002) + delete (Super only, P2003); category/group/frequency option data
- [x] `+page.svelte` — `DataTable` (Name, Category, Group, Frequency) + modal with
TipTap content editor for Super users
- [x] Update `page.server.spec.ts` and `page.svelte.spec.ts` (drop `svelte-admin` mocks)
### Phase 10 — API Keys page (operator page)
- [x] `+page.server.ts` — remove `ROLES` import (`$lib/svelte-admin/roles` deleted); use literal
- [x] `+page.svelte` — consistent styling; keep single-page rotate flow (modal conversion deferred)
- [x] Document deferred modal conversion in appendix
### Phase 11 — Tests & validation
- [x] Update `license-keys/page.server.spec.ts` (drop `svelte-admin` mocks)
- [x] `npm run check`
- [x] `npm run lint` (pre-existing `no-explicit-any` spec debt remains — see Appendix C)
- [x] `npm run test`
- [x] `npm run build` (needs env vars exported during `vite build`)
- [x] Final commit; mark every checkbox complete
---
## Appendix A — Profiles bug root causes
1. **Skip-first `lastForm` bug.** Both modals guard against the initial `null` form
with `if (lastForm === null) { lastForm = form; return; }`, which also swallows the
_first real submit result_ — the first "Add Address" save creates the DB row but the
UI never reacts (no close, no toast, no appended row). Fix: `if (!form || form === lastForm) return;`.
2. **Stale modal state across records.** `{#if editRecord}` keeps the modal mounted
between different rows, while the `seeded` flag never resets — the previous row's
addresses/user data leak in, and `record` stays stale (the reported
"my name didn't update"). Fix: `{#key editRecord?.id}` remount + per-record reseed.
3. **User data is only a snapshot.** The modal rendered `record.user.name / email`
directly. With the unified email model this display goes away; the form fields are
controlled by local `$state` and the server keeps Profile + User in sync.
## Appendix B — API Keys decision note
The API Keys page is an operator page (key rotation), not a CRUD table. It does not use
`DataTable`. Recommended future change: present the rotation confirmation and the new
key in shared `AdminModal` panels with in-modal status. That conversion is **deferred**
pending review. What was done now: compile fix (replaced the deleted `ROLES` import)
and consistent styling (simple slate card layout, indigo action buttons, red emergency
button) so the page compiles and matches the site visually.
## Appendix C — Validation caveats
1. **`npm run check`** required renaming `src/routes/(protected)/admin/profiles/+page.server.spec.ts`
to `page.server.spec.ts`: SvelteKit reserves files whose names start with `+`. The `+`-prefix
broke `svelte-kit sync` and thus the whole `check`/`build` pipeline. Renamed.
2. **`npm run lint`** previously reported 142 `no-explicit-any` errors across 14 pre-existing spec
files (user/account, dashboard, license-keys, login/register/reset, etc.). A scoped override now
disables that rule for `**/*.spec.ts`, since test doubles commonly need `any`. All non-spec lint
errors were fixed; `npm run lint` is green.
3. **`npm run build`** needs `BETTER_AUTH_SECRET`/`BETTER_AUTH_URL`/`DATABASE_URL` exported in the
shell, because `src/lib/auth.ts` reads `$env/dynamic/private` (empty at `vite build` time) and
Better Auth rejects the default secret. Export the vars before building.
+20
View File
@@ -97,6 +97,26 @@ Requires API key.
---
# Schedule (v2)
Endpoint:
GET /api/v2/schedule?email=<email>&licenseKey=<key>
Authenticates with the paired `email` + `licenseKey` query parameters and
validates only that the key belongs to the account (not that the license is
active/entitled). Returns the user's Blocks with start/end times and the IDs of
the selected Proclamations (content is fetched separately via
`/api/v2/proclamations`). Each block also carries `dynamicAudio`; when true, the
kiosk enables dynamic audio for the block's duration, overriding any audio
selections made on the device while the block is active. Overnight blocks (end
clock time before start) are supported and flagged with `overnight: true` plus a
wrapping `durationMinutes`. Rate limited to 10 requests per minute per IP.
Failure messages follow the existing v2 shape:
`not-authorized` or `this key doesn't appear to belong to you`.
---
# GHL Account Creation Webhook
Endpoint:
-330
View File
@@ -1,330 +0,0 @@
# API Updates — Category Flattening, Groups, Subscription Level Removal
**Release:** v2 schema change (migration `20260821000000_flat_categories_groups_drop_subscription_levels`)
**Status:** Breaking changes. Clients must be updated.
## Summary of changes
| Endpoint | Change |
| ----------------------------------------- | ------------------------------------------------------------------------------------ |
| `GET /api/v2/categories` | **Breaking**: `parentId` removed from every record. Categories are now a flat list. |
| `GET /api/v2/groups` | **New**: returns the group list (former sub-categories). |
| `GET /api/v2/proclamations` | **Breaking**: `groupId` added; `subscriptionLevelId` removed. |
| `GET /api/v2/subscription-levels` | **Removed**: subscription levels no longer exist. |
| `POST /api/v2/license` | **Breaking**: response no longer contains `subscription_level`. |
| `POST /api/v2/automations/create/account` | **Breaking**: `subscription_level` is no longer accepted or required in the payload. |
| `POST /api/v1/license` | **Breaking**: response no longer contains `subscription_level`. |
| `POST /api/v1/automations/create/account` | **Breaking**: `subscription_level` is no longer accepted or required in the payload. |
| `GET /api/v1/categories` | Payload implicitly changed: rows no longer contain `parentId`. |
### Data model mapping
- Former **parent** categories (`Spiritual`, `Physical`, `Mental`, `Emotional`, `Financial`,
`Social`, `Biblical`, ...) became the flat **Category** list.
- Former **child** sub-categories (`General`, `Specific`, `Precise`, `Prayers`, `Scripture`)
became **Groups**.
- A proclamation's former child category becomes its `group`; its former parent category becomes
its `category`. A proclamation may move between categories without changing its group.
- Proclamations that had no sub-category (e.g. under `Custom (Client Specific)`) were assigned to
the `General` group.
- Every proclamation now has exactly one category and one group.
---
## OpenAPI 3.0 specification
```yaml
openapi: 3.0.3
info:
title: Radiant Wave Portal API
version: 2.1.0
description: |
Changes in this release:
* ProclamationCategory hierarchy flattened; parentId removed everywhere.
* New ProclamationGroup resource replaces sub-categories as a movable filter.
* SubscriptionLevel resource and all level-based gating removed.
paths:
/api/v2/categories:
get:
summary: List categories (flat)
description: >
Returns all proclamation categories as a flat, ordered list.
BREAKING: the previous version returned a hierarchical list where each
record carried a nullable parentId linking children to parents. That
field no longer exists.
tags: [Categories]
responses:
'200':
description: Flat category list.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
examples:
newResponse:
summary: New response
value:
- id: 4
name: Spiritual
sortOrder: 0
- id: 2
name: Physical
sortOrder: 1
oldResponseDeprecated:
summary: Old (removed) response shape
value:
- id: 5
name: General
sortOrder: 0
parentId: 4
- id: 9
name: Specific
sortOrder: 1
parentId: 4
/api/v2/groups:
get:
summary: List groups
description: >
NEW ENDPOINT. Returns all proclamation groups ordered by sortOrder.
Groups replace the former sub-categories and act as a movable filter:
a proclamation can be moved from one category to another while keeping
its group.
tags: [Groups]
responses:
'200':
description: Group list.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Group'
example:
- id: 1
name: General
sortOrder: 0
- id: 2
name: Specific
sortOrder: 1
/api/v2/proclamations:
get:
summary: List proclamations
description: >
Returns proclamations with their canonical frequency id and optional
color palette. BREAKING: each record now carries groupId (required,
always present) and no longer carries subscriptionLevelId. When the
optional entitlement headers are supplied, only the caller's entitled
proclamations are returned; there is no longer any per-level gating.
tags: [Proclamations]
parameters:
- in: header
name: x-license-key
required: false
schema: { type: string }
description: License key. Must be paired with x-email.
- in: header
name: x-email
required: false
schema: { type: string }
description: Account email. Must be paired with x-license-key.
responses:
'200':
description: Proclamation list.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Proclamation'
examples:
newResponse:
summary: New response
value:
- id: b40fdcb9-703c-420e-af32-a081964c6f08
name: Peace & Relaxation
content: <p>The text content</p>
categoryId: 4
groupId: 1
solfeggioFrequencyId: 5
colorPalette:
[
'#32CD32',
'#3CB371',
'#90EE90',
'#98FB98',
'#ADFFA9',
'#C8FFD2',
'#F0FFF0'
]
oldResponseDeprecated:
summary: Old (removed) response shape
value:
- id: b40fdcb9-703c-420e-af32-a081964c6f08
name: Peace & Relaxation
content: <p>The text content</p>
categoryId: 5
solfeggioFrequencyId: 5
subscriptionLevelId: 1
'400':
description: Entitlement headers incomplete or invalid.
/api/v2/subscription-levels:
get:
summary: REMOVED
deprecated: true
description: >
This endpoint was removed. Subscription levels no longer exist in the
data model; clients must stop calling it.
tags: [Removed]
responses:
'404':
description: Route no longer exists.
/api/v2/license:
post:
summary: Validate a license key
description: >
Validates a key/email pair. BREAKING: the response no longer contains a
subscription_level object.
tags: [License]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [license_key, email_address]
properties:
license_key: { type: string, example: ABCD-EFGH-IJKL-MNOP }
email_address: { type: string, format: email }
responses:
'200':
description: Key is valid.
content:
application/json:
schema:
$ref: '#/components/schemas/LicenseResponse'
examples:
newResponse:
summary: New response
value:
key: ABCD-EFGH-IJKL-MNOP
email_address: user@example.com
oldResponseDeprecated:
summary: Old (removed) response shape
value:
key: ABCD-EFGH-IJKL-MNOP
email_address: user@example.com
subscription_level:
id: 2
name: Advanced
description: General + Specific
'400':
description: Validation failure (missing fields, unknown key, wrong owner).
/api/v2/automations/create/account:
post:
summary: Create account + license key (GoHighLevel automation)
description: >
Creates a portal account when needed and issues a license key.
BREAKING: subscription_level was removed from the request contract. The
field is neither required nor validated anymore; sending it is ignored.
Signature headers (x-timestamp, x-nonce, x-signature) are unchanged.
tags: [Automations]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [email]
properties:
email: { type: string, format: email }
name: { type: string }
responses:
'200':
description: Account and license key created.
'400':
description: Missing/invalid email.
'401':
description: Invalid signature.
components:
schemas:
Category:
type: object
required: [id, name, sortOrder]
properties:
id: { type: integer }
name: { type: string }
sortOrder: { type: integer }
Group:
type: object
required: [id, name, sortOrder]
properties:
id: { type: integer }
name: { type: string }
sortOrder: { type: integer }
Proclamation:
type: object
required: [id, name, content, categoryId, groupId, solfeggioFrequencyId]
properties:
id:
type: string
format: uuid
name: { type: string }
content: { type: string, description: HTML formatted content. }
categoryId:
type: integer
description: Flat category this proclamation belongs to.
groupId:
type: integer
description: >
Group membership (movable filter). Independent of categoryId;
changing the category does not change the group.
solfeggioFrequencyId:
type: integer
minimum: 1
maximum: 9
description: Canonical kiosk frequency id (not the DB autoincrement id).
colorPalette:
type: array
items: { type: string }
minItems: 7
maxItems: 7
description: Present only when a palette exists for the frequency.
LicenseResponse:
type: object
required: [key, email_address]
properties:
key: { type: string }
email_address: { type: string, format: email }
```
---
## Client migration checklist
1. **Kiosk / API consumers**
- Stop reading `parentId`; treat `/api/v2/categories` as flat.
- Fetch `/api/v2/groups` once at startup for the filter list.
- Read `groupId` from each proclamation record for filtering.
- Remove all references to `subscriptionLevelId` and `/api/v2/subscription-levels`.
- Remove `subscription_level` handling from license validation responses.
2. **GoHighLevel automation**
- Remove `subscription_level` from the account-creation webhook payload
(optional but recommended; the field is now ignored).
3. **Admin portal**
- Manage categories under Admin → Proclamation Categories (flat list, drag to reorder).
- Manage groups under Admin → Proclamation Groups (drag to reorder).
- Proclamations now select one Category and one Group independently.
+612
View File
@@ -0,0 +1,612 @@
openapi: 3.0.3
info:
title: Radiant Wave Portal API (v2)
version: 2.0.0
description: |
Read API used by the Radiant Wave kiosk plus the automation onboarding
webhook.
## Authentication
The v2 API is **not** API-key gated. The former bearer-token middleware in
`src/hooks.server.ts` is present but disabled, so v2 routes are served
without a global API key.
Two endpoints carry their own credential semantics:
- `GET /api/v2/proclamations` optionally accepts the paired
`x-license-key` / `x-email` headers to scope results to an entitled user.
Supplying neither returns the full catalogue; supplying only one, or a
pair that does not match, is rejected.
- `POST /api/v2/automations/create/account` is protected by an HMAC-SHA256
request signature (see the operation description).
servers:
- url: https://portal.radiantwavetech.com
description: Production
- url: http://localhost:5173
description: Local development
tags:
- name: Catalogue
description: Reference data for categories, groups, frequencies and palettes.
- name: Proclamations
description: Proclamation content, optionally scoped by license entitlement.
- name: License
description: License key validation.
- name: Schedule
description: A user's daily listening schedule, consumed by the kiosk.
- name: Automations
description: Signed machine-to-machine webhooks.
paths:
/api/v2/categories:
get:
tags: [Catalogue]
summary: List proclamation categories
operationId: listCategories
responses:
'200':
description: All proclamation categories.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
/api/v2/proclamation-categories:
get:
tags: [Catalogue]
summary: List proclamation categories (dedicated alias)
description: Alias of `/api/v2/categories`; returns the identical payload.
operationId: listProclamationCategories
responses:
'200':
description: All proclamation categories.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Category'
/api/v2/groups:
get:
tags: [Catalogue]
summary: List proclamation groups
description: Groups are ordered by `sortOrder` ascending.
operationId: listGroups
responses:
'200':
description: All proclamation groups.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Group'
/api/v2/proclamation-groups:
get:
tags: [Catalogue]
summary: List proclamation groups (dedicated alias)
description: Alias of `/api/v2/groups`; returns the identical payload.
operationId: listProclamationGroups
responses:
'200':
description: All proclamation groups.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Group'
/api/v2/frequencies:
get:
tags: [Catalogue]
summary: List canonical solfeggio frequencies
description: |
Returns the nine canonical frequencies (ids 1-9) the kiosk expects.
These are static values, not the portal database autoincrement ids.
operationId: listFrequencies
responses:
'200':
description: The nine canonical frequencies.
content:
application/json:
schema:
type: array
minItems: 9
maxItems: 9
items:
$ref: '#/components/schemas/Frequency'
/api/v2/color-palettes:
get:
tags: [Catalogue]
summary: List color palettes per canonical frequency
description: |
Returns one 7-color palette for each canonical frequency id (1-9).
Palette rows stored in the database take precedence; ids without a row
fall back to the built-in canonical palette.
operationId: listColorPalettes
responses:
'200':
description: Nine palettes, one per canonical frequency id.
content:
application/json:
schema:
type: array
minItems: 9
maxItems: 9
items:
$ref: '#/components/schemas/ColorPalette'
/api/v2/proclamations:
get:
tags: [Proclamations]
summary: List proclamations
description: |
Without entitlement headers, returns the full proclamation catalogue.
With both `x-license-key` and `x-email`, returns only the proclamations
assigned to the matching user. Both headers must be present together and
the key must belong to the user.
Proclamations whose frequency has no canonical id (for example 936 Hz)
are omitted from the response.
operationId: listProclamations
parameters:
- in: header
name: x-license-key
schema:
type: string
required: false
description: License key. Must be supplied together with `x-email`.
- in: header
name: x-email
schema:
type: string
format: email
required: false
description: Account email. Must be supplied together with `x-license-key`.
responses:
'200':
description: Proclamation records with canonical frequency ids and embedded palettes.
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/Proclamation'
'400':
description: |
Missing entitlement or the supplied key/email pair is invalid.
Message is `not-authorized`, or
`this key doesn't appear to belong to you` when the key does not
belong to the specified user.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/license:
post:
tags: [License]
summary: Validate a license key
description: |
Validates that the license key exists, is active, and belongs to the
account matching `email_address`. A successful validation records
`lastValidatedAt` on the key.
operationId: validateLicense
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/LicenseRequest'
responses:
'200':
description: The validated key and its owner email.
content:
application/json:
schema:
$ref: '#/components/schemas/LicenseResponse'
'400':
description: |
Validation failure or malformed body. Possible messages:
`content-type must be application/json`, `invalid JSON body`,
`request body is required`, `license_key is required`,
`email_address is required`, `license key not found`,
`license key is not active`, `user does not exist`,
`key does not belong to specified user`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'413':
description: The request body exceeded the 64 KiB limit.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/schedule:
get:
tags: [Schedule]
summary: Get a user's listening schedule (query-param auth)
description: |
Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates via
the paired `email` + `licenseKey` **query parameters** and validates only
that the key belongs to the account for that email. License
active/entitlement is deliberately not checked here: an invalid license
would already lock the kiosk out, so this endpoint only checks the
pairing.
Returns every Block with its start/end times and the ordered list of
selected Proclamation IDs. Overnight blocks (an end clock time before
the start) are supported and flagged with `overnight: true` and a
wrapping `durationMinutes`. Each block also carries `dynamicAudio`;
when true, the kiosk enables dynamic audio for the block's duration,
overriding any audio selections made on the device. Proclamation
content is not included; the kiosk fetches it separately via
`/api/v2/proclamations`.
operationId: getSchedule
parameters:
- in: query
name: email
required: true
schema:
type: string
format: email
description: Account email.
- in: query
name: licenseKey
required: true
schema:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
description: License key. The legacy alias `key` is also accepted.
responses:
'200':
description: The user's schedule (possibly empty).
content:
application/json:
schema:
$ref: '#/components/schemas/ScheduleResponse'
example:
email: jane@example.com
blocks:
- id: 3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f
startTime: '21:00'
startMinutes: 1260
endTime: '09:00'
endMinutes: 540
overnight: true
durationMinutes: 720
dynamicAudio: true
proclamationIds:
- b40fdcb9-703c-420e-af32-a081964c6f08
- 7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b
'400':
description: |
Missing/invalid parameters, unknown key or user, or the key does not
belong to the email's account. Possible messages: `not-authorized`,
`this key doesn't appear to belong to you`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/automations/create/account:
post:
tags: [Automations]
summary: Create an account and license key (signed webhook)
description: |
Machine-to-machine onboarding webhook. The request must be signed with
HMAC-SHA256 using `API_V2_ONBOARDING_SECRET`.
The canonical string that is signed is the newline-joined concatenation
of:
```
POST
<path>
<x-timestamp>
<x-nonce>
<raw request body>
```
where `<path>` is `/api/v2/automations/create/account`. The signature is
the lowercase hex digest.
On success, creates the user if they do not already exist, generates a
license key, emails it, and returns the user id. Invalid or missing
signatures return `401 { "error": "invalid signature" }` (the same
response is used for every signature failure, including replay and
timestamp skew).
operationId: createAutomationAccount
parameters:
- in: header
name: x-timestamp
required: true
schema:
type: string
description: Unix epoch seconds. Must be within 300 seconds of the server clock.
- in: header
name: x-nonce
required: true
schema:
type: string
description: Unique value per request. Replays within the skew window are rejected.
- in: header
name: x-signature
required: true
schema:
type: string
pattern: '^[0-9a-f]{64}$'
description: Lowercase hex HMAC-SHA256 of the canonical request string.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingRequest'
responses:
'200':
description: Account and license key created.
content:
application/json:
schema:
$ref: '#/components/schemas/OnboardingResponse'
'400':
description: |
Malformed or invalid request. Possible messages:
`request body is required`, `invalid JSON body`, or
`email is required`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'401':
description: Signature missing, malformed, expired, replayed, or incorrect.
content:
application/json:
schema:
type: object
required: [error]
properties:
error:
type: string
example: invalid signature
'413':
description: The request body exceeded the 64 KiB limit.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: |
Rate limit exceeded. Limits are 5 requests per minute per IP and
3 requests per 24 hours per email address.
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'500':
description: The account could not be created.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
components:
schemas:
Category:
type: object
required: [id, name, sortOrder]
properties:
id:
type: integer
name:
type: string
sortOrder:
type: integer
Group:
type: object
required: [id, name, sortOrder]
properties:
id:
type: integer
name:
type: string
sortOrder:
type: integer
Frequency:
type: object
required: [id, name, value]
properties:
id:
type: integer
minimum: 1
maximum: 9
description: Canonical id used by the kiosk.
name:
type: string
value:
type: integer
description: Frequency in Hz.
ColorPalette:
type: object
required: [solfeggioFrequencyId, colors]
properties:
solfeggioFrequencyId:
type: integer
minimum: 1
maximum: 9
colors:
type: array
description: Seven hex colors, ordered light to dark.
minItems: 1
items:
type: string
pattern: '^#[0-9A-Fa-f]{6}$'
Proclamation:
type: object
required: [id, name, content, categoryId, groupId, solfeggioFrequencyId]
properties:
id:
type: string
format: uuid
name:
type: string
content:
type: string
description: HTML content.
categoryId:
type: integer
groupId:
type: integer
solfeggioFrequencyId:
type: integer
minimum: 1
maximum: 9
colorPalette:
type: array
description: Present only when a palette exists for the canonical frequency id.
minItems: 1
items:
type: string
pattern: '^#[0-9A-Fa-f]{6}$'
LicenseRequest:
type: object
required: [license_key, email_address]
properties:
license_key:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
example: ABCD-EFGH-IJKL-MNOP
email_address:
type: string
format: email
maxLength: 254
LicenseResponse:
type: object
required: [key, email_address]
properties:
key:
type: string
example: ABCD-EFGH-IJKL-MNOP
email_address:
type: string
format: email
ScheduleBlock:
type: object
required:
[
id,
startTime,
startMinutes,
endTime,
endMinutes,
overnight,
durationMinutes,
dynamicAudio,
proclamationIds
]
properties:
id:
type: string
format: uuid
startTime:
type: string
example: '06:00'
description: Wall-clock start time as `HH:MM`.
startMinutes:
type: integer
minimum: 0
maximum: 1440
description: Minutes from midnight.
endTime:
type: string
example: '08:00'
description: Wall-clock end time as `HH:MM`.
endMinutes:
type: integer
minimum: 0
maximum: 1440
description: Minutes from midnight.
overnight:
type: boolean
description: True when the end clock time is at or before the start, i.e. the block crosses midnight.
durationMinutes:
type: integer
minimum: 1
description: Block length in minutes. An overnight block counts the wrap past midnight as the next day.
dynamicAudio:
type: boolean
description: |
When true, dynamic audio is enabled for the block's duration, overriding any
audio selections made on the device while the block is active.
proclamationIds:
type: array
description: IDs of the selected Proclamations, in play order.
items:
type: string
format: uuid
ScheduleResponse:
type: object
required: [email, blocks]
properties:
email:
type: string
format: email
blocks:
type: array
maxItems: 4
items:
$ref: '#/components/schemas/ScheduleBlock'
OnboardingRequest:
type: object
required: [email]
properties:
email:
type: string
format: email
maxLength: 254
name:
type: string
maxLength: 120
description: Optional display name. Defaults to the email local-part.
OnboardingResponse:
type: object
required: [success, message, user_id]
properties:
success:
type: boolean
message:
type: string
example: Account and license key created
user_id:
type: string
ErrorMessage:
type: object
required: [message]
properties:
message:
type: string
+166
View File
@@ -0,0 +1,166 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Verify your email address</title>
</head>
<body
style="
margin: 0;
padding: 0;
background-color: #f1f5f9;
-webkit-font-smoothing: antialiased;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
"
>
<!-- Preheader (hidden) -->
<div style="display: none; max-height: 0; overflow: hidden; opacity: 0; color: transparent">
Confirm your email address to activate your Radiant Wave account.
</div>
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color: #f1f5f9; padding: 32px 12px"
>
<tr>
<td align="center">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="
width: 100%;
max-width: 600px;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
"
>
<tr>
<td style="padding: 28px 32px 0 32px">
<span
style="
display: inline-block;
background-color: #4f46e5;
border-radius: 10px;
padding: 10px 14px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
"
>
Radiant Wave
</span>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 0 32px">
<h1
style="
margin: 0 0 12px 0;
color: #0f172a;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
"
>
Confirm your email address
</h1>
<p style="margin: 0 0 8px 0; color: #334155; font-size: 16px; line-height: 1.6">
Thanks for signing up for Radiant Wave. Verify your email address to activate your
account.
</p>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius: 10px; background-color: #4f46e5">
<a
href="{{{verification_link}}}"
target="_blank"
style="
display: inline-block;
padding: 14px 28px;
color: #ffffff;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border-radius: 10px;
"
>
Verify Email Address
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<p style="margin: 0; color: #64748b; font-size: 13px; line-height: 1.6">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p
style="
margin: 6px 0 0 0;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
"
>
<a
href="{{{verification_link}}}"
target="_blank"
style="color: #4f46e5; text-decoration: underline"
>{{{verification_link}}}</a
>
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 32px 32px">
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 0 0 16px 0" />
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
This link expires in 1 hour. If you didn't create an account, you can safely
ignore this email.
</p>
</td>
</tr>
</table>
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px"
>
<tr>
<td style="padding: 20px 8px; text-align: center">
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
Sent to {{{target_email_address}}}<br />
&copy; Radiant Wave Technologies
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+184
View File
@@ -0,0 +1,184 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Your Radiant Wave License Key</title>
</head>
<body
style="
margin: 0;
padding: 0;
background-color: #f1f5f9;
-webkit-font-smoothing: antialiased;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
"
>
<!-- Preheader (hidden) -->
<div style="display: none; max-height: 0; overflow: hidden; opacity: 0; color: transparent">
Here's your Radiant Wave license key.
</div>
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color: #f1f5f9; padding: 32px 12px"
>
<tr>
<td align="center">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="
width: 100%;
max-width: 600px;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
"
>
<tr>
<td style="padding: 28px 32px 0 32px">
<span
style="
display: inline-block;
background-color: #4f46e5;
border-radius: 10px;
padding: 10px 14px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
"
>
Radiant Wave
</span>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 0 32px">
<h1
style="
margin: 0 0 12px 0;
color: #0f172a;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
"
>
Your license key
</h1>
<p style="margin: 0 0 8px 0; color: #334155; font-size: 16px; line-height: 1.6">
Hi {{{firstName}}}, your Radiant Wave account is ready. Here's your license key —
you'll need it to activate the app.
</p>
</td>
</tr>
<tr>
<td style="padding: 20px 32px 0 32px">
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 12px"
>
<tr>
<td style="padding: 18px 20px; text-align: center">
<p
style="
margin: 0 0 6px 0;
color: #64748b;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
"
>
Your License Key
</p>
<p
style="
margin: 0;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 20px;
font-weight: 700;
letter-spacing: 0.06em;
word-break: break-all;
"
>
{{{license_key}}}
</p>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 20px 32px 0 32px">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius: 10px; background-color: #4f46e5">
<a
href="https://portal.radiantwavetech.com/dashboard"
target="_blank"
style="
display: inline-block;
padding: 14px 28px;
color: #ffffff;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border-radius: 10px;
"
>
Go to Your Dashboard
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 32px 32px">
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 0 0 16px 0" />
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
Keep this key safe — anyone who has it can activate the app. If you didn't request
this, contact support.
</p>
</td>
</tr>
</table>
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px"
>
<tr>
<td style="padding: 20px 8px; text-align: center">
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
&copy; Radiant Wave Technologies
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+166
View File
@@ -0,0 +1,166 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Reset your Radiant Wave password</title>
</head>
<body
style="
margin: 0;
padding: 0;
background-color: #f1f5f9;
-webkit-font-smoothing: antialiased;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
"
>
<!-- Preheader (hidden) -->
<div style="display: none; max-height: 0; overflow: hidden; opacity: 0; color: transparent">
Choose a new password for your Radiant Wave account.
</div>
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color: #f1f5f9; padding: 32px 12px"
>
<tr>
<td align="center">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="
width: 100%;
max-width: 600px;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
"
>
<tr>
<td style="padding: 28px 32px 0 32px">
<span
style="
display: inline-block;
background-color: #4f46e5;
border-radius: 10px;
padding: 10px 14px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
"
>
Radiant Wave
</span>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 0 32px">
<h1
style="
margin: 0 0 12px 0;
color: #0f172a;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
"
>
Reset your password
</h1>
<p style="margin: 0 0 8px 0; color: #334155; font-size: 16px; line-height: 1.6">
We received a request to reset your Radiant Wave password. Click the button below
to choose a new one.
</p>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius: 10px; background-color: #4f46e5">
<a
href="{{{reset_link}}}"
target="_blank"
style="
display: inline-block;
padding: 14px 28px;
color: #ffffff;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border-radius: 10px;
"
>
Reset Password
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<p style="margin: 0; color: #64748b; font-size: 13px; line-height: 1.6">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p
style="
margin: 6px 0 0 0;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
"
>
<a
href="{{{reset_link}}}"
target="_blank"
style="color: #4f46e5; text-decoration: underline"
>{{{reset_link}}}</a
>
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 32px 32px">
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 0 0 16px 0" />
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
This link expires in 1 hour. If you didn't request a password reset, you can
safely ignore this email — your password won't change.
</p>
</td>
</tr>
</table>
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px"
>
<tr>
<td style="padding: 20px 8px; text-align: center">
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
Sent to {{{target_email_address}}}<br />
&copy; Radiant Wave Technologies
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<title>Welcome to Radiant Wave</title>
</head>
<body
style="
margin: 0;
padding: 0;
background-color: #f1f5f9;
-webkit-font-smoothing: antialiased;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
"
>
<!-- Preheader (hidden) -->
<div style="display: none; max-height: 0; overflow: hidden; opacity: 0; color: transparent">
Set your password to finish setting up your Radiant Wave account.
</div>
<table
role="presentation"
width="100%"
cellpadding="0"
cellspacing="0"
style="background-color: #f1f5f9; padding: 32px 12px"
>
<tr>
<td align="center">
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="
width: 100%;
max-width: 600px;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 16px;
overflow: hidden;
"
>
<tr>
<td style="padding: 28px 32px 0 32px">
<span
style="
display: inline-block;
background-color: #4f46e5;
border-radius: 10px;
padding: 10px 14px;
color: #ffffff;
font-size: 16px;
font-weight: 700;
letter-spacing: -0.01em;
"
>
Radiant Wave
</span>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 0 32px">
<h1
style="
margin: 0 0 12px 0;
color: #0f172a;
font-size: 24px;
line-height: 1.25;
font-weight: 700;
"
>
Welcome to Radiant Wave
</h1>
<p style="margin: 0 0 8px 0; color: #334155; font-size: 16px; line-height: 1.6">
An account has been created for you. Set a password to finish setting up your
account and access your dashboard.
</p>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius: 10px; background-color: #4f46e5">
<a
href="{{{registration_link}}}"
target="_blank"
style="
display: inline-block;
padding: 14px 28px;
color: #ffffff;
font-size: 16px;
font-weight: 600;
text-decoration: none;
border-radius: 10px;
"
>
Set Your Password
</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding: 16px 32px 0 32px">
<p style="margin: 0; color: #64748b; font-size: 13px; line-height: 1.6">
If the button doesn't work, copy and paste this link into your browser:
</p>
<p
style="
margin: 6px 0 0 0;
word-break: break-all;
font-size: 13px;
line-height: 1.6;
"
>
<a
href="{{{registration_link}}}"
target="_blank"
style="color: #4f46e5; text-decoration: underline"
>{{{registration_link}}}</a
>
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 0 32px">
<p style="margin: 0; color: #334155; font-size: 15px; line-height: 1.6">
After you set your password, sign in and complete your profile to get started.
</p>
</td>
</tr>
<tr>
<td style="padding: 24px 32px 32px 32px">
<hr style="border: none; border-top: 1px solid #e2e8f0; margin: 0 0 16px 0" />
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
This link expires in 1 hour. If you didn't expect this email, you can safely
ignore it.
</p>
</td>
</tr>
</table>
<table
role="presentation"
width="600"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px"
>
<tr>
<td style="padding: 20px 8px; text-align: center">
<p style="margin: 0; color: #94a3b8; font-size: 12px; line-height: 1.6">
Sent to {{{target_email_address}}}<br />
&copy; Radiant Wave Technologies
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
+9
View File
@@ -5,6 +5,15 @@ import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: 'prisma/schema.prisma',
// Legacy subscription tables are retained in the database but are no longer
// managed by Prisma Migrate. They must never be dropped (production may not
// own them and the data is kept for recovery).
experimental: {
externalTables: true
},
tables: {
external: ['public.SubscriptionLevel', 'public.ProclamationCategorySubscriptionLevel']
},
migrations: {
path: 'prisma/migrations',
seed: 'tsx prisma/seed.ts'
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "user" ALTER COLUMN "roleId" SET DEFAULT 4;
@@ -0,0 +1,24 @@
-- Retain the legacy subscription artifacts as externally managed data.
--
-- SubscriptionLevel and ProclamationCategorySubscriptionLevel are no longer
-- modeled by the application. They are declared as external tables in
-- prisma.config.ts so Prisma Migrate ignores them entirely and never drops
-- them (production may not own these tables, and their data is kept for
-- recovery).
--
-- This migration only touches Prisma-managed tables to decouple them from the
-- legacy FK. It is intentionally idempotent so it applies cleanly to
-- production (columns present, NOT NULL, FK present), a fresh database built
-- from migration history, and development databases that already removed the
-- legacy objects.
-- Ensure the legacy columns exist. On databases built from migration history
-- these already exist; this re-adds them on environments that dropped them.
ALTER TABLE "Proclamation" ADD COLUMN IF NOT EXISTS "subscriptionLevelId" INTEGER;
ALTER TABLE "LicenseKey" ADD COLUMN IF NOT EXISTS "level" TEXT;
-- Decouple from SubscriptionLevel: drop the FK and relax NOT NULL so the
-- application can keep inserting rows without a legacy subscription value.
ALTER TABLE "Proclamation" DROP CONSTRAINT IF EXISTS "Proclamation_subscriptionLevelId_fkey";
ALTER TABLE "Proclamation" ALTER COLUMN "subscriptionLevelId" DROP NOT NULL;
ALTER TABLE "LicenseKey" ALTER COLUMN "level" DROP NOT NULL;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LicenseKey" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LicenseKey" ADD COLUMN "lastValidatedAt" TIMESTAMP(3);
@@ -0,0 +1,30 @@
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT,
"email" TEXT,
"roleId" INTEGER,
"roleName" TEXT,
"page" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entity" TEXT,
"entityId" TEXT,
"before" JSONB,
"after" JSONB,
"ip" TEXT,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- CreateIndex
CREATE INDEX "AuditLog_roleId_idx" ON "AuditLog"("roleId");
-- CreateIndex
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
-- CreateIndex
CREATE INDEX "AuditLog_page_idx" ON "AuditLog"("page");
@@ -0,0 +1,53 @@
-- CreateTable
CREATE TABLE "Schedule" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Schedule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Block" (
"id" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"startMinutes" INTEGER NOT NULL,
"endMinutes" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Block_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BlockProclamations" (
"blockId" TEXT NOT NULL,
"proclamationId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlockProclamations_pkey" PRIMARY KEY ("blockId","proclamationId")
);
-- CreateIndex
CREATE UNIQUE INDEX "Schedule_userId_key" ON "Schedule"("userId");
-- CreateIndex
CREATE INDEX "Block_scheduleId_idx" ON "Block"("scheduleId");
-- CreateIndex
CREATE INDEX "BlockProclamations_proclamationId_idx" ON "BlockProclamations"("proclamationId");
-- AddForeignKey
ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Block" ADD CONSTRAINT "Block_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "Schedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BlockProclamations" ADD CONSTRAINT "BlockProclamations_blockId_fkey" FOREIGN KEY ("blockId") REFERENCES "Block"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BlockProclamations" ADD CONSTRAINT "BlockProclamations_proclamationId_fkey" FOREIGN KEY ("proclamationId") REFERENCES "Proclamation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Block" ADD COLUMN "dynamicAudio" BOOLEAN NOT NULL DEFAULT false;
+90 -8
View File
@@ -21,13 +21,14 @@ model User {
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
roleId Int?
roleId Int? @default(4) // New users default to "User"
role UserRole? @relation(fields: [roleId], references: [id])
sessions Session[]
accounts Account[]
profiles Profile[]
licenseKeys LicenseKey[]
userProclamations UserProclamations[]
schedule Schedule?
@@unique([email])
@@map("user")
@@ -130,6 +131,10 @@ model Proclamation {
groupId Int
group ProclamationGroup @relation(fields: [groupId], references: [id])
userProclamations UserProclamations[]
blockProclamations BlockProclamations[]
// Legacy subscription-level FK. Retained in the database but unmanaged; the
// FK constraint is dropped and the value is left NULL. Hidden from the client.
subscriptionLevelId Int? @ignore
}
model ProclamationGroup {
@@ -183,7 +188,7 @@ model ProclamationCategoryLegacy {
// `@@ignore` so Prisma remains aware of the table but it is excluded from the
// generated client and Prisma's own migrations.
model ProclamationCategorySubscriptionLevelLegacy {
categoryId Int
categoryId Int
subscriptionLevelId Int
@@id([categoryId, subscriptionLevelId])
@@ -192,12 +197,17 @@ model ProclamationCategorySubscriptionLevelLegacy {
}
model LicenseKey {
id String @id @default(uuid())
key String @unique
active Boolean
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
email String?
id String @id @default(uuid())
key String @unique
active Boolean
createdAt DateTime @default(now())
lastValidatedAt DateTime?
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
email String?
// Legacy subscription level. Retained in the database but unmanaged; hidden
// from the client and left NULL.
level String? @ignore
@@index([userId])
@@index([email])
@@ -221,3 +231,75 @@ model ColorPalette {
@@map("ColorPalette")
}
// Append-only audit trail of database mutations. Written centrally by the
// Prisma extension in src/lib/db.ts; read by the admin Audit Log page.
model AuditLog {
id String @id @default(uuid())
createdAt DateTime @default(now())
userId String?
email String?
roleId Int?
roleName String?
page String
action String
entity String?
entityId String?
before Json?
after Json?
ip String?
@@index([createdAt])
@@index([roleId])
@@index([action])
@@index([page])
@@map("AuditLog")
}
// A user's daily listening schedule. Exactly one per user (enforced by the
// unique userId); contains 0-4 time Blocks. Times are wall-clock minutes from
// midnight (0-1440), interpreted by the kiosk in the user's configured
// timezone. The portal never stores a timezone.
model Schedule {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
blocks Block[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// A single non-overlapping time range within a Schedule, plus the ordered set
// of selected Proclamations to play during it. Overnight ranges are disallowed
// in v1, so startMinutes is always less than endMinutes.
model Block {
id String @id @default(uuid())
scheduleId String
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
startMinutes Int
endMinutes Int
// When true, the kiosk enables dynamic audio for the duration of this block,
// overriding any audio selections made on the device.
dynamicAudio Boolean @default(false)
proclamations BlockProclamations[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([scheduleId])
}
// Join table: the Proclamations selected for a Block. Carries the Proclamation
// content for playback via the API; the portal UI only ever reads
// title/category/group.
model BlockProclamations {
blockId String
block Block @relation(fields: [blockId], references: [id], onDelete: Cascade)
proclamationId String
proclamation Proclamation @relation(fields: [proclamationId], references: [id])
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([blockId, proclamationId])
@@index([proclamationId])
}
+3 -3
View File
@@ -1,11 +1,11 @@
import type { Session, User } from 'better-auth';
import type { auth } from '$lib/auth';
import type { ApiKeyRecord } from '$lib/server/api-key';
declare global {
namespace App {
interface Locals {
session: Session | null;
user: User | null;
session: typeof auth.$Infer.Session.session | null;
user: typeof auth.$Infer.Session.user | null;
apiKey?: ApiKeyRecord;
}
}
+13
View File
@@ -3,6 +3,19 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function () {
try {
const theme = localStorage.getItem('theme');
const dark =
theme === 'dark' ||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches);
document.documentElement.classList.toggle('dark', dark);
} catch (e) {
// ignore
}
})();
</script>
%sveltekit.head%
</head>
+79 -58
View File
@@ -3,71 +3,92 @@ import { svelteKitHandler } from 'better-auth/svelte-kit';
import { building } from '$app/environment';
import { env } from '$env/dynamic/private';
import { validateApiKey } from '$lib/server/api-key';
import { runWithAuditContext, type AuditContext } from '$lib/server/audit-context';
import { clientIp } from '$lib/server/v2-helpers';
function contextFor(
event: { url: URL; request: Request },
session?: App.Locals['user']
): AuditContext {
return {
userId: session?.id ?? null,
email: session?.email ?? null,
roleId: session?.roleId ?? null,
page: event.url.pathname,
ip: clientIp(event.request)
};
}
export async function handle({ event, resolve }) {
// v2 auth is ALWAYS enforced — no API_KEYS_ACTIVE bypass. The only
// exceptions are the signed onboarding routes (HMAC-protected).
// TODO: re-enable when v2 API key auth is ready.
// const isV2 = event.url.pathname.startsWith('/api/v2/');
// const isSignedOnboarding = event.url.pathname.startsWith('/api/v2/automations/');
//
// if (isV2 && !isSignedOnboarding) {
// const authHeader = event.request.headers.get('authorization');
// if (!authHeader?.startsWith('Bearer ')) {
// return new Response(JSON.stringify({ error: 'API key required' }), {
// status: 401,
// headers: { 'content-type': 'application/json' }
// });
// }
//
// const key = authHeader.slice(7);
// const record = await validateApiKey(key);
// if (!record) {
// return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
// status: 401,
// headers: { 'content-type': 'application/json' }
// });
// }
//
// event.locals.apiKey = record;
//
// return resolve(event);
// }
return runWithAuditContext(contextFor(event), async () => {
// v2 auth is ALWAYS enforced — no API_KEYS_ACTIVE bypass. The only
// exceptions are the signed onboarding routes (HMAC-protected).
// TODO: re-enable when v2 API key auth is ready.
// const isV2 = event.url.pathname.startsWith('/api/v2/');
// const isSignedOnboarding = event.url.pathname.startsWith('/api/v2/automations/');
//
// if (isV2 && !isSignedOnboarding) {
// const authHeader = event.request.headers.get('authorization');
// if (!authHeader?.startsWith('Bearer ')) {
// return new Response(JSON.stringify({ error: 'API key required' }), {
// status: 401,
// headers: { 'content-type': 'application/json' }
// });
// }
//
// const key = authHeader.slice(7);
// const record = await validateApiKey(key);
// if (!record) {
// return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
// status: 401,
// headers: { 'content-type': 'application/json' }
// });
// }
//
// event.locals.apiKey = record;
//
// return resolve(event);
// }
if (
event.url.pathname.startsWith('/api/v1/') &&
!event.url.pathname.startsWith('/api/v1/automations/')
) {
if (env.API_KEYS_ACTIVE !== 'false') {
const authHeader = event.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'API key required' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
if (
event.url.pathname.startsWith('/api/v1/') &&
!event.url.pathname.startsWith('/api/v1/automations/')
) {
if (env.API_KEYS_ACTIVE !== 'false') {
const authHeader = event.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'API key required' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
const key = authHeader.slice(7);
const record = await validateApiKey(key);
if (!record) {
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
}
const key = authHeader.slice(7);
const record = await validateApiKey(key);
if (!record) {
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
return resolve(event);
}
return resolve(event);
}
const session = await auth.api.getSession({
headers: event.request.headers
});
const session = await auth.api.getSession({
headers: event.request.headers
if (session) {
event.locals.session = session.session;
event.locals.user = session.user;
return runWithAuditContext(contextFor(event, session.user), () =>
svelteKitHandler({ event, resolve, auth, building })
);
}
return svelteKitHandler({ event, resolve, auth, building });
});
if (session) {
event.locals.session = session.session;
event.locals.user = session.user;
}
return svelteKitHandler({ event, resolve, auth, building });
}
+35
View File
@@ -5,13 +5,40 @@ import { sveltekitCookies } from 'better-auth/svelte-kit';
import { getRequestEvent } from '$app/server';
import { sendMail } from './server/resend';
import { sendVerificationEmail } from './server/email-verification';
import { sendRegistrationEmail } from './server/registration-email';
import { env } from '$env/dynamic/private';
/**
* An admin-created user is invited with a set-password link. That reuses the
* password-reset token flow but marks the request with `invite=1` so we send a
* welcome/registration email instead of a "you requested a reset" email.
*/
function isRegistrationInvite(url: string): boolean {
try {
const callbackURL = new URL(url).searchParams.get('callbackURL') ?? '';
return callbackURL.includes('invite=1');
} catch {
return false;
}
}
export const auth = betterAuth({
baseURL: env.BETTER_AUTH_URL,
database: prismaAdapter(prisma, {
provider: 'postgresql'
}),
user: {
// Expose our custom role column on the session user so role checks can
// read `locals.user.roleId` without a database round-trip.
additionalFields: {
roleId: {
type: 'number',
required: false,
defaultValue: 4,
input: false
}
}
},
emailVerification: {
sendOnSignUp: true,
sendVerificationEmail: async ({ user, token }) => {
@@ -27,8 +54,16 @@ export const auth = betterAuth({
enabled: true,
requireEmailVerification: true,
sendResetPassword: async ({ user, url }) => {
if (isRegistrationInvite(url)) {
void sendRegistrationEmail({ email: user.email, url }).catch((e) => {
console.error('sendRegistrationEmail template email failed:', e);
});
return;
}
void sendMail({
to: user.email,
subject: 'Reset your Radiant Wave password',
templateId: 'password-reset-email',
data: {
target_email_address: user.email,
@@ -0,0 +1,25 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
description = '',
children
}: {
title: string;
description?: string;
children: Snippet;
} = $props();
</script>
<section class="mt-8">
<div>
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
{#if description}
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">{description}</p>
{/if}
</div>
<div class="mt-4">
{@render children()}
</div>
</section>
+14 -10
View File
@@ -95,14 +95,14 @@
<div class="mx-auto w-full">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-xl font-semibold text-slate-900">{title}</h2>
<h2 class="text-xl font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
<input
type="search"
bind:value={search}
placeholder={searchPlaceholder}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none sm:w-64"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none sm:w-64 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{#if onNew}
<button
@@ -128,11 +128,13 @@
</div>
</div>
<div class="w-full overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm">
<table class="w-full min-w-max text-left text-sm text-slate-700">
<div
class="w-full overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<table class="w-full min-w-max text-left text-sm text-slate-700 dark:text-slate-300">
<thead>
<tr
class="border-b border-slate-200 bg-slate-100 text-xs font-semibold tracking-wide text-slate-500"
class="border-b border-slate-200 bg-slate-100 text-xs font-semibold tracking-wide text-slate-500 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400"
>
{#if draggable}
<th scope="col" class="w-10 px-4 py-3"></th>
@@ -145,8 +147,10 @@
<tbody>
{#each rows as record, i (rowKey(record, i))}
<tr
class="transition {i % 2 === 0 ? 'bg-white' : 'bg-slate-50'} {onRowClick
? 'cursor-pointer hover:bg-indigo-50'
class="transition {i % 2 === 0
? 'bg-white dark:bg-slate-900'
: 'bg-slate-50 dark:bg-slate-950'} {onRowClick
? 'cursor-pointer hover:bg-indigo-50 dark:hover:bg-indigo-950'
: ''}"
{draggable}
ondragstart={(e) => handleDragStart(e, i)}
@@ -158,7 +162,7 @@
{#if draggable}
<td class="px-4 py-3" onclick={(e) => e.stopPropagation()} aria-hidden="true">
<span
class="cursor-grab text-slate-400 select-none active:cursor-grabbing"
class="cursor-grab text-slate-400 select-none active:cursor-grabbing dark:text-slate-500"
title="Drag to reorder">⠿</span
>
</td>
@@ -175,7 +179,7 @@
copyValue(column.value(record));
}}
title="Copy to clipboard"
class="ml-1 inline-flex text-slate-400 transition hover:text-indigo-600"
class="ml-1 inline-flex text-slate-400 transition hover:text-indigo-600 dark:text-slate-500 dark:hover:text-indigo-400"
>
<svg
viewBox="0 0 16 16"
@@ -203,7 +207,7 @@
<tr>
<td
colspan={columns.length + (draggable ? 1 : 0)}
class="bg-white px-4 py-10 text-center text-slate-500"
class="bg-white dark:bg-slate-900 px-4 py-10 text-center text-slate-500 dark:text-slate-400"
>
No results found.
</td>
+5 -5
View File
@@ -53,18 +53,18 @@
role="dialog"
aria-modal="true"
aria-label={title}
class="relative z-10 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl"
class="relative z-10 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4"
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
>
<h2 class="text-lg font-bold text-slate-900">{title}</h2>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">{title}</h2>
<div class="flex items-center gap-2">
{#if canDelete && recordId}
<button
type="button"
onclick={confirmDelete}
class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white"
class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white dark:bg-red-950/50 dark:text-red-400 dark:ring-red-900"
>
Delete
</button>
@@ -73,7 +73,7 @@
type="button"
onclick={onClose}
aria-label="Close"
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600"
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
>
&times;
</button>
+3 -3
View File
@@ -26,15 +26,15 @@
{#if visible}
<div
role="alert"
class="fixed right-4 bottom-4 z-50 flex items-center gap-3 rounded-lg border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-white shadow-lg"
class="fixed right-4 bottom-4 z-50 flex items-center gap-3 rounded-lg border border-slate-700 bg-slate-900 px-4 py-3 text-sm text-white shadow-lg dark:border-slate-300 dark:bg-slate-950"
>
<span>{message}</span>
<span class="text-xs text-slate-400">{remaining}s</span>
<span class="text-xs text-slate-400 dark:text-slate-500">{remaining}s</span>
<button
type="button"
onclick={dismiss}
aria-label="Dismiss"
class="rounded p-1 text-slate-400 transition hover:text-white"
class="rounded p-1 text-slate-400 transition hover:text-white dark:text-slate-500"
>
&times;
</button>
+3 -1
View File
@@ -3,7 +3,9 @@
</script>
<div class="mx-auto w-full max-w-md px-4 py-8 sm:py-12">
<div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
<div
class="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900"
>
{@render children()}
</div>
</div>
@@ -0,0 +1,437 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import ProclamationBrowser from './ProclamationBrowser.svelte';
import {
MINUTE_STEP,
formatTime,
isOvernight,
validateBlock,
type BlockSibling
} from '$lib/schedule-utils';
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
open = false,
mode = 'create',
blockId,
initialStartMinutes = 9 * 60,
initialEndMinutes = 10 * 60,
initialProclamationIds = [],
initialDynamicAudio = false,
categories = [],
groups = [],
proclamations = [],
existingBlocks = [],
createAction = '?/createBlock',
updateAction = '?/updateBlock',
deleteAction = '?/deleteBlock',
onClose,
onSaved
}: {
open?: boolean;
mode?: 'create' | 'edit';
blockId?: string;
initialStartMinutes?: number;
initialEndMinutes?: number;
initialProclamationIds?: string[];
initialDynamicAudio?: boolean;
categories?: BrowserCategory[];
groups?: BrowserGroup[];
proclamations?: BrowserProclamation[];
existingBlocks?: BlockSibling[];
createAction?: string;
updateAction?: string;
deleteAction?: string;
onClose?: () => void;
onSaved?: () => void;
} = $props();
let step = $state<'details' | 'browse'>('details');
let startHour = $state('9');
let startMinute = $state('0');
let startPeriod = $state<'AM' | 'PM'>('AM');
let endHour = $state('10');
let endMinute = $state('0');
let endPeriod = $state<'AM' | 'PM'>('AM');
let selectedIds = $state<string[]>([]);
let dynamicAudio = $state(false);
let error = $state('');
const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1));
const periodOptions: ('AM' | 'PM')[] = ['AM', 'PM'];
function minuteOptions(current: string): string[] {
const base = Array.from({ length: 60 / MINUTE_STEP }, (_, i) => String(i * MINUTE_STEP));
if (base.includes(current)) return base;
return [...base, current].sort((a, b) => Number(a) - Number(b));
}
function to12Hour(totalMinutes: number): { hour: number; minute: number; period: 'AM' | 'PM' } {
const hours24 = Math.floor(totalMinutes / 60) % 24;
return {
hour: hours24 % 12 === 0 ? 12 : hours24 % 12,
minute: totalMinutes % 60,
period: hours24 < 12 ? 'AM' : 'PM'
};
}
function to24Hour(hour12: string, minute: string, period: 'AM' | 'PM'): number {
const hours = (Number(hour12) % 12) + (period === 'PM' ? 12 : 0);
return (hours * 60 + Number(minute)) | 0;
}
// Re-seed the fields whenever the modal is (re)opened.
$effect(() => {
if (!open) return;
const start = to12Hour(initialStartMinutes);
startHour = String(start.hour);
startMinute = String(start.minute);
startPeriod = start.period;
const end = to12Hour(initialEndMinutes);
endHour = String(end.hour);
endMinute = String(end.minute);
endPeriod = end.period;
selectedIds = [...initialProclamationIds];
dynamicAudio = initialDynamicAudio;
error = '';
step = 'details';
});
const startMinutes = $derived(to24Hour(startHour, startMinute, startPeriod));
const endMinutes = $derived(to24Hour(endHour, endMinute, endPeriod));
const overnight = $derived(
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
);
const siblings = $derived(existingBlocks.filter((block) => block.id !== blockId));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
function validate(): string | null {
const result = validateBlock(
{ startMinutes, endMinutes, proclamationIds: selectedIds },
siblings,
{ isCreate: mode === 'create' }
);
return result.ok ? null : result.error;
}
const handleSubmit: SubmitFunction = ({ formData, cancel }) => {
const intent = formData.get('intent');
if (intent !== 'delete') {
const validationError = validate();
if (validationError) {
error = validationError;
cancel();
return;
}
}
error = '';
return async ({ result, update }) => {
if (result.type === 'failure') {
const data = result.data as { error?: string } | undefined;
error = data?.error ?? 'Unable to save block.';
await update({ reset: false });
return;
}
if (result.type === 'error') {
error = 'Unable to save block.';
return;
}
await update();
onSaved?.();
};
};
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose?.();
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onkeydown={handleKeydown}
role="presentation"
>
<button
type="button"
aria-label="Close dialog"
class="absolute inset-0 h-full w-full cursor-default"
onclick={() => onClose?.()}
></button>
<div
role="dialog"
aria-modal="true"
aria-label={mode === 'edit' ? 'Edit block' : 'Create block'}
class="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">
{step === 'browse'
? 'Select Proclamations'
: mode === 'edit'
? 'Edit Block'
: 'Create Block'}
</h2>
<button
type="button"
onclick={() => onClose?.()}
aria-label="Close"
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
>
&times;
</button>
</div>
<form
method="POST"
action={mode === 'edit' ? updateAction : createAction}
use:enhance={handleSubmit}
class="flex min-h-0 flex-1 flex-col"
>
{#if mode === 'edit' && blockId}
<input type="hidden" name="id" value={blockId} />
{/if}
<input type="hidden" name="startMinutes" value={startMinutes} />
<input type="hidden" name="endMinutes" value={endMinutes} />
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
<input type="hidden" name="dynamicAudio" value={dynamicAudio ? 'true' : 'false'} />
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{#if error}
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
role="alert"
>
{error}
</div>
{/if}
{#if step === 'details'}
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
<div class="flex flex-col gap-4">
<div>
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>Start time</span
>
<div class="flex items-center gap-2">
<select
aria-label="Start hour"
onchange={(e) => (startHour = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each hourOptions as hour (hour)}
<option value={hour} selected={startHour === hour}>{hour}</option>
{/each}
</select>
<span class="text-slate-400">:</span>
<select
aria-label="Start minute"
onchange={(e) => (startMinute = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each minuteOptions(startMinute) as minute (minute)}
<option value={minute} selected={startMinute === minute}>
{minute.padStart(2, '0')}
</option>
{/each}
</select>
<select
aria-label="Start period"
onchange={(e) => (startPeriod = e.currentTarget.value as 'AM' | 'PM')}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each periodOptions as period (period)}
<option value={period} selected={startPeriod === period}>{period}</option>
{/each}
</select>
</div>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>End time</span
>
<div class="flex items-center gap-2">
<select
aria-label="End hour"
onchange={(e) => (endHour = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each hourOptions as hour (hour)}
<option value={hour} selected={endHour === hour}>{hour}</option>
{/each}
</select>
<span class="text-slate-400">:</span>
<select
aria-label="End minute"
onchange={(e) => (endMinute = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each minuteOptions(endMinute) as minute (minute)}
<option value={minute} selected={endMinute === minute}>
{minute.padStart(2, '0')}
</option>
{/each}
</select>
<select
aria-label="End period"
onchange={(e) => (endPeriod = e.currentTarget.value as 'AM' | 'PM')}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each periodOptions as period (period)}
<option value={period} selected={endPeriod === period}>{period}</option>
{/each}
</select>
</div>
</div>
<div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
Proclamations ({selectedTitles.length})
</span>
<button
type="button"
onclick={() => (step = 'browse')}
class="rounded-md border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-sm font-medium text-indigo-700 transition hover:bg-indigo-100 dark:border-indigo-900 dark:bg-indigo-950/50 dark:text-indigo-300"
>
Browse proclamations
</button>
</div>
</div>
<div class="flex items-center gap-2">
<label
class="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"
>
<input
type="checkbox"
checked={dynamicAudio}
onchange={(e) => (dynamicAudio = e.currentTarget.checked)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
Enable Dynamic Audio
</label>
<span class="group relative inline-flex">
<button
type="button"
aria-label="What is Dynamic Audio?"
aria-describedby="dynamic-audio-help"
class="flex h-5 w-5 items-center justify-center rounded-full border border-slate-300 text-xs font-semibold text-slate-500 transition hover:border-indigo-400 hover:text-indigo-600 focus:border-indigo-500 focus:outline-none dark:border-slate-600 dark:text-slate-400"
>
?
</button>
<span
id="dynamic-audio-help"
role="tooltip"
class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-64 -translate-x-1/2 rounded-md bg-slate-900 px-3 py-2 text-xs font-normal text-white opacity-0 shadow-lg transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 dark:bg-slate-700"
>
Dynamic audio will be enabled for this block, while this block is playing.
This will override any audio selections you have made on your device while
this block is active.
</span>
</span>
</div>
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
{formatTime(startMinutes)} – {formatTime(endMinutes)}{overnight
? ' (next day)'
: ''}
</p>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations selected.
</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title (title)}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
{:else}
<ProclamationBrowser
{categories}
{groups}
{proclamations}
{selectedIds}
onChange={(ids) => (selectedIds = ids)}
/>
{/if}
</div>
<div
class="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
>
<div class="flex items-center gap-3">
{#if step === 'details' && mode === 'edit' && blockId}
<button
type="submit"
name="intent"
value="delete"
formaction={deleteAction}
class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white dark:bg-red-950/50 dark:text-red-400 dark:ring-red-900"
>
Delete
</button>
{/if}
<button
type="button"
onclick={() => onClose?.()}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
</div>
<div class="flex items-center gap-3">
{#if step === 'browse'}
<button
type="button"
onclick={() => (step = 'details')}
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Accept
</button>
{:else}
<button
type="submit"
name="intent"
value="save"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{mode === 'edit' ? 'Save Block' : 'Create Block'}
</button>
{/if}
</div>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,193 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
const { submitRef } = vi.hoisted(() => ({
submitRef: {
current: null as unknown as (opts: { formData: FormData; cancel?: () => void }) => unknown
}
}));
vi.mock('$app/forms', () => ({
enhance: (_node: unknown, submit: unknown) => {
submitRef.current = submit as typeof submitRef.current;
return () => {};
}
}));
import BlockModal from './BlockModal.svelte';
const categories = [{ id: 1, name: 'Physical' }];
const groups = [{ id: 1, name: 'Prayers' }];
const proclamations = [
{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(BlockModal, {
open: true,
mode: 'create',
categories,
groups,
proclamations,
existingBlocks: [],
initialStartMinutes: 6 * 60,
initialEndMinutes: 8 * 60,
initialProclamationIds: ['p1'],
onClose: () => {},
onSaved: () => {},
...overrides
});
}
function submit() {
const form = document.querySelector('form') as HTMLFormElement;
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
}
describe('BlockModal', () => {
it('uses separate hour and minute selects for start and end', async () => {
mount();
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Start minute$/ }))
.toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toBeInTheDocument();
});
it('pre-fills the selects from the initial times', async () => {
mount({ initialStartMinutes: 7 * 60 + 30, initialEndMinutes: 9 * 60 + 15 });
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('7');
await expect.element(page.getByRole('combobox', { name: /^Start minute$/ })).toHaveValue('30');
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('9');
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toHaveValue('15');
});
it('uses a 12-hour clock with an AM/PM select for each time', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 0 });
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('9');
await expect.element(page.getByRole('combobox', { name: /^Start period$/ })).toHaveValue('PM');
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('12');
await expect.element(page.getByRole('combobox', { name: /^End period$/ })).toHaveValue('AM');
// No 24-hour option exists any more.
await expect
.element(page.getByRole('combobox', { name: /^Start hour$/ }))
.not.toHaveValue('21');
});
it('recomputes the time when the AM/PM switch changes', async () => {
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
const period = (await page
.getByRole('combobox', { name: /^End period$/ })
.element()) as HTMLSelectElement;
period.value = 'PM';
period.dispatchEvent(new Event('change', { bubbles: true }));
await expect.element(page.getByText('9:00 AM – 10:00 PM')).toBeInTheDocument();
});
it('shows the review times and selected proclamation titles', async () => {
mount();
await expect.element(page.getByText('6:00 AM – 8:00 AM')).toBeInTheDocument();
await expect.element(page.getByText('Peace')).toBeInTheDocument();
});
it('marks an overnight block in the review', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 9 * 60 });
await expect.element(page.getByText('9:00 PM – 9:00 AM (next day)')).toBeInTheDocument();
});
it('rejects an overlapping time range with the specific error', async () => {
mount({
existingBlocks: [{ id: 'b1', startMinutes: 6 * 60, endMinutes: 8 * 60 }],
initialStartMinutes: 7 * 60,
initialEndMinutes: 9 * 60
});
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect
.element(page.getByText(/This overlaps with your 6:00 AM–8:00 AM block\./))
.toBeInTheDocument();
});
it('requires at least one proclamation', async () => {
mount({ initialProclamationIds: [] });
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
});
it('does not show an error for a valid selection', async () => {
mount();
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
const result = submit();
expect(typeof result).toBe('function');
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('shows Cancel with Accept in the browse step, and Accept returns to the details', async () => {
mount();
const browse = page.getByRole('button', { name: 'Browse proclamations' });
await expect.element(browse).toBeInTheDocument();
(browse.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
const accept = page.getByRole('button', { name: 'Accept' });
await expect.element(accept).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Create Block' }))
.not.toBeInTheDocument();
(accept.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Create Block' })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Accept' })).not.toBeInTheDocument();
});
it('offers an Enable Dynamic Audio checkbox with a help tooltip', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
await expect
.element(page.getByText(/Dynamic audio will be enabled for this block/))
.toBeInTheDocument();
});
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
mount({ initialDynamicAudio: true });
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
});
it('toggles the dynamic audio flag in the submitted payload', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
(checkbox.element() as HTMLInputElement).click();
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
await expect.poll(() => hidden.value).toBe('true');
});
});
@@ -0,0 +1,173 @@
<script lang="ts">
import {
MINUTES_PER_DAY,
SNAP_MINUTES,
blockSegments,
formatTime,
isOvernight,
snapMinutes
} from '$lib/schedule-utils';
type ScheduleBlockView = {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string }[];
};
let {
blocks = [],
onCreate,
onEdit
}: {
blocks?: ScheduleBlockView[];
onCreate?: (startMinutes: number, endMinutes: number) => void;
onEdit?: (block: ScheduleBlockView) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
const defaultDuration = 60;
let track = $state<HTMLDivElement>();
let drag = $state<{ anchor: number; current: number } | null>(null);
function minutesFromEvent(event: PointerEvent): number {
if (!track) return 0;
const rect = track.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
}
function handlePointerDown(event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const minutes = minutesFromEvent(event);
drag = { anchor: minutes, current: minutes };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
event.preventDefault();
}
function handlePointerMove(event: PointerEvent) {
if (!drag) return;
drag = { anchor: drag.anchor, current: minutesFromEvent(event) };
}
function handlePointerUp(event: PointerEvent) {
if (!drag) return;
const { anchor, current } = drag;
drag = null;
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
const start = Math.min(anchor, current);
const end = Math.max(anchor, current);
if (end - start >= SNAP_MINUTES) {
onCreate?.(start, end);
return;
}
// A simple click creates a default-length block starting at that slot.
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
const clickEnd = Math.max(
defaultDuration,
Math.min(MINUTES_PER_DAY, clickStart + defaultDuration)
);
onCreate?.(Math.max(0, clickStart), clickEnd);
}
const preview = $derived.by(() => {
if (!drag) return null;
const start = Math.min(drag.anchor, drag.current);
const end = Math.max(drag.anchor, drag.current);
return {
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
});
// Overnight blocks render as two segments: start→midnight and midnight→end.
const blockViews = $derived(
blocks.map((block) => ({
block,
overnight: isOvernight(block),
segments: blockSegments(block).map(([segmentStart, segmentEnd]) => ({
top: (segmentStart / MINUTES_PER_DAY) * 100,
height: ((segmentEnd - segmentStart) / MINUTES_PER_DAY) * 100
}))
}))
);
</script>
<div class="flex gap-2">
<div class="relative h-[720px] w-14 shrink-0" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
class="absolute right-1 -translate-y-1/2 text-xs text-slate-400 dark:text-slate-500"
style={`top:${(hour / 24) * 100}%`}
>
{String(hour).padStart(2, '0')}:00
</div>
{/each}
</div>
<div
bind:this={track}
role="presentation"
class="relative h-[720px] flex-1 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
style="touch-action: none;"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={() => (drag = null)}
>
{#each hourMarks as hour (hour)}
<div
class="pointer-events-none absolute inset-x-0 border-t border-slate-100 dark:border-slate-800"
style={`top:${(hour / 24) * 100}%`}
></div>
{/each}
{#if preview}
<div
class="pointer-events-none absolute inset-x-1 rounded-md bg-indigo-200/60 dark:bg-indigo-700/40"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each blockViews as view (view.block.id)}
{#each view.segments as segment, segmentIndex (`${view.block.id}-${segmentIndex}`)}
<button
type="button"
onpointerdown={(event) => event.stopPropagation()}
onclick={(event) => {
event.stopPropagation();
onEdit?.(view.block);
}}
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
class="absolute inset-x-1 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${segment.top}%;height:${segment.height}%`}
>
{#if segmentIndex === 0}
<span class="block font-semibold">
{formatTime(view.block.startMinutes)} – {formatTime(
view.block.endMinutes
)}{view.overnight ? ' (next day)' : ''}
</span>
{:else}
<span class="block font-semibold">↳ continues</span>
{/if}
<span class="block truncate">
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
</span>
</button>
{/each}
{/each}
{#if blocks.length === 0}
<p
class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 px-4 text-center text-sm text-slate-400 dark:text-slate-500"
>
Click or drag to add a block.
</p>
{/if}
</div>
</div>
@@ -0,0 +1,211 @@
<script lang="ts">
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
categories = [],
groups = [],
proclamations = [],
selectedIds = [],
onChange
}: {
categories: BrowserCategory[];
groups: BrowserGroup[];
proclamations: BrowserProclamation[];
selectedIds: string[];
onChange?: (ids: string[]) => void;
} = $props();
// Categories start collapsed. Groups are pre-expanded, so we track the
// smaller set of groups the user has explicitly collapsed.
let openCategoryIds = $state<number[]>([]);
let closedGroupIds = $state<number[]>([]);
const selectedSet = $derived(new Set(selectedIds));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
type GroupView = { group: BrowserGroup; items: BrowserProclamation[] };
type CategoryView = {
category: BrowserCategory;
groups: GroupView[];
ungrouped: BrowserProclamation[];
};
const view = $derived.by<CategoryView[]>(() =>
categories.map((category) => {
const inCategory = proclamations.filter((p) => p.categoryId === category.id);
const groupViews = groups
.map((group) => ({
group,
items: inCategory.filter((p) => p.groupId === group.id)
}))
.filter((groupView) => groupView.items.length > 0);
const ungrouped = inCategory.filter((p) => p.groupId === null || p.groupId === undefined);
return { category, groups: groupViews, ungrouped };
})
);
function toggleCategory(id: number) {
openCategoryIds = openCategoryIds.includes(id)
? openCategoryIds.filter((value) => value !== id)
: [...openCategoryIds, id];
}
function toggleGroup(id: number) {
closedGroupIds = closedGroupIds.includes(id)
? closedGroupIds.filter((value) => value !== id)
: [...closedGroupIds, id];
}
function toggleProclamation(id: string) {
const next = selectedSet.has(id)
? selectedIds.filter((value) => value !== id)
: [...selectedIds, id];
onChange?.(next);
}
</script>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<div
class="max-h-96 overflow-y-auto rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
{#if categories.length === 0}
<p class="p-4 text-sm text-slate-500 dark:text-slate-400">No categories available.</p>
{:else}
<ul class="divide-y divide-slate-100 dark:divide-slate-800">
{#each view as categoryView (categoryView.category.id)}
{@const isCategoryOpen = openCategoryIds.includes(categoryView.category.id)}
<li>
<button
type="button"
aria-expanded={isCategoryOpen}
onclick={() => toggleCategory(categoryView.category.id)}
class="flex w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-semibold text-slate-800 transition hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
>
<span>{categoryView.category.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isCategoryOpen ? 'rotate-90' : ''}"
aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg
>
</button>
{#if isCategoryOpen}
<div class="px-4 pb-3">
{#if categoryView.groups.length === 0 && categoryView.ungrouped.length === 0}
<p class="py-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations in this category.
</p>
{/if}
{#each categoryView.groups as groupView (groupView.group.id)}
{@const isGroupOpen = !closedGroupIds.includes(groupView.group.id)}
<div class="mt-2">
<button
type="button"
aria-expanded={isGroupOpen}
onclick={() => toggleGroup(groupView.group.id)}
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs font-semibold tracking-wide text-slate-500 uppercase transition hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
<span>{groupView.group.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isGroupOpen ? 'rotate-180' : ''}"
aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg
>
</button>
{#if isGroupOpen}
<ul class="mt-1 space-y-0.5">
{#each groupView.items as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
{#if categoryView.ungrouped.length > 0}
<div class="mt-3 border-t border-slate-100 pt-2 dark:border-slate-800">
<ul class="space-y-0.5">
{#each categoryView.ungrouped as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">
Selected ({selectedTitles.length})
</h3>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">No proclamations selected.</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title, index (selectedIds[index])}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
@@ -0,0 +1,107 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import ProclamationBrowser from './ProclamationBrowser.svelte';
const categories = [
{ id: 1, name: 'Physical' },
{ id: 2, name: 'Mental' },
{ id: 3, name: 'Empty' }
];
const groups = [
{ id: 1, name: 'Prayers' },
{ id: 2, name: 'Scripture' }
];
const proclamations = [
{ id: 'p1', name: 'Healing', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 },
{ id: 'p3', name: 'Focus', categoryId: 1, groupId: null },
{ id: 'p4', name: 'Calm', categoryId: 2, groupId: 2 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(ProclamationBrowser, {
categories,
groups,
proclamations,
selectedIds: [],
onChange: () => {},
...overrides
});
}
async function click(locator: { element: () => HTMLElement | SVGElement }) {
const el = locator.element() as HTMLElement;
el.click();
}
describe('ProclamationBrowser', () => {
it('collapses categories by default', async () => {
mount();
await expect.element(page.getByRole('button', { name: 'Physical' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
});
it('reveals pre-expanded groups and their proclamations when a category opens', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Prayers')).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Strength' })).toBeInTheDocument();
});
it('hides groups that have no proclamations in the category', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Scripture')).not.toBeInTheDocument();
});
it('lists ungrouped proclamations below the groups', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Focus' })).toBeInTheDocument();
});
it('reports toggled selections through onChange', async () => {
const onChange = vi.fn();
mount({ selectedIds: ['p1'], onChange });
await click(page.getByRole('button', { name: 'Physical' }));
await click(page.getByRole('checkbox', { name: 'Strength' }));
expect(onChange).toHaveBeenCalledWith(['p1', 'p2']);
await click(page.getByRole('checkbox', { name: 'Healing' }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('shows selected titles in the summary and keeps selection across collapse', async () => {
mount({ selectedIds: ['p1'] });
await expect.element(page.getByText('Selected (1)')).toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const checkbox = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(checkbox.checked).toBe(true);
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const reopened = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(reopened.checked).toBe(true);
});
});
+161 -7
View File
@@ -2,18 +2,172 @@ import pg from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '$lib/generated/prisma/client';
import { env } from '$env/dynamic/private';
import { getAuditContext } from '$lib/server/audit-context';
import { describeAction, entityIdFor, sanitizeAuditValue, shouldAudit } from '$lib/server/audit';
let _prisma: PrismaClient | undefined;
let _base: PrismaClient | undefined;
let _pool: pg.Pool | undefined;
function getBase(): { base: PrismaClient; pool: pg.Pool } {
if (!_base || !_pool) {
_pool = new pg.Pool({ connectionString: env.DATABASE_URL });
const adapter = new PrismaPg(_pool);
_base = new PrismaClient({ adapter });
}
return { base: _base, pool: _pool };
}
const roleNameCache = new Map<number, string>();
async function resolveRoleName(base: PrismaClient, roleId: number | null): Promise<string | null> {
if (roleId === null) return null;
const cached = roleNameCache.get(roleId);
if (cached) return cached;
try {
const role = await base.userRole.findUnique({
where: { id: roleId },
select: { name: true }
});
if (role?.name) {
roleNameCache.set(roleId, role.name);
return role.name;
}
} catch (e) {
console.error('Failed to resolve role name for audit log:', e);
}
return null;
}
type Delegate = {
findFirst?: (args: unknown) => Promise<unknown>;
findMany?: (args: unknown) => Promise<unknown>;
};
function delegateFor(base: PrismaClient, model: string): Delegate | undefined {
const key = model.charAt(0).toLowerCase() + model.slice(1);
return (base as unknown as Record<string, unknown>)[key] as Delegate | undefined;
}
async function readBefore(
base: PrismaClient,
model: string,
operation: string,
args: unknown
): Promise<unknown> {
if (operation.startsWith('create')) return null;
const delegate = delegateFor(base, model);
if (!delegate) return null;
const where = (args as { where?: unknown } | null)?.where;
try {
if (operation === 'updateMany' || operation === 'deleteMany') {
return delegate.findMany ? await delegate.findMany({ where, take: 50 }) : null;
}
return delegate.findFirst ? await delegate.findFirst({ where }) : null;
} catch {
return null;
}
}
async function writeAuditRow(
pool: pg.Pool,
base: PrismaClient,
entry: {
userId: string | null;
email: string | null;
roleId: number | null;
page: string;
action: string;
entity: string;
entityId: string | null;
before: unknown;
after: unknown;
ip: string | null;
}
): Promise<void> {
const roleName = await resolveRoleName(base, entry.roleId);
await pool.query(
`INSERT INTO "AuditLog"
("id", "createdAt", "userId", "email", "roleId", "roleName", "page", "action", "entity", "entityId", "before", "after", "ip")
VALUES ($1, now(), $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, $12)`,
[
crypto.randomUUID(),
entry.userId,
entry.email,
entry.roleId,
roleName,
entry.page,
entry.action,
entry.entity,
entry.entityId,
entry.before === null ? null : JSON.stringify(entry.before),
entry.after === null ? null : JSON.stringify(entry.after),
entry.ip
]
);
}
function getPrisma(): PrismaClient {
if (!_prisma) {
const pool = new pg.Pool({
connectionString: env.DATABASE_URL
});
if (_prisma) return _prisma;
const adapter = new PrismaPg(pool);
_prisma = new PrismaClient({ adapter });
}
const { base, pool } = getBase();
const extended = base.$extends({
query: {
$allModels: {
async $allOperations({ model, operation, args, query }) {
if (!shouldAudit(model, operation)) {
return query(args);
}
const before = await readBefore(base, model, operation, args);
const result = await query(args);
try {
const ctx = getAuditContext();
const res = result as { id?: unknown; email?: string; roleId?: number | null } | null;
let userId = ctx?.userId ?? null;
let email = ctx?.email ?? null;
let roleId = ctx?.roleId ?? null;
// Self-signup: attribute the new user to itself since no session exists yet.
if (!userId && model === 'User' && operation === 'create' && res) {
userId = (res.id as string | undefined) ?? null;
email = res.email ?? null;
roleId = res.roleId ?? null;
}
const after =
operation === 'updateMany' || operation === 'deleteMany'
? { count: (result as { count?: number } | null)?.count ?? null }
: operation.startsWith('delete')
? null
: result;
await writeAuditRow(pool, base, {
userId,
email,
roleId,
page: ctx?.page ?? '/system',
action: describeAction(operation),
entity: model,
entityId: entityIdFor(args, result, before),
before: sanitizeAuditValue(before),
after: sanitizeAuditValue(after),
ip: ctx?.ip ?? null
});
} catch (e) {
console.error('Failed to write audit log:', e);
}
return result;
}
}
}
});
_prisma = extended as unknown as PrismaClient;
return _prisma;
}
+20
View File
@@ -92,3 +92,23 @@ export type ApiKey = Prisma.ApiKeyModel
*
*/
export type ColorPalette = Prisma.ColorPaletteModel
/**
* Model AuditLog
*
*/
export type AuditLog = Prisma.AuditLogModel
/**
* Model Schedule
*
*/
export type Schedule = Prisma.ScheduleModel
/**
* Model Block
*
*/
export type Block = Prisma.BlockModel
/**
* Model BlockProclamations
*
*/
export type BlockProclamations = Prisma.BlockProclamationsModel
+20
View File
@@ -116,3 +116,23 @@ export type ApiKey = Prisma.ApiKeyModel
*
*/
export type ColorPalette = Prisma.ColorPaletteModel
/**
* Model AuditLog
*
*/
export type AuditLog = Prisma.AuditLogModel
/**
* Model Schedule
*
*/
export type Schedule = Prisma.ScheduleModel
/**
* Model Block
*
*/
export type Block = Prisma.BlockModel
/**
* Model BlockProclamations
*
*/
export type BlockProclamations = Prisma.BlockProclamationsModel
@@ -253,6 +253,57 @@ export type JsonWithAggregatesFilterBase<$PrismaModel = never> = {
_max?: Prisma.NestedJsonFilter<$PrismaModel>
}
export type JsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonNullableFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonNullableFilterBase<$PrismaModel>>, 'path'>>
export type JsonNullableFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type JsonNullableWithAggregatesFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, Exclude<keyof Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>,
Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<JsonNullableWithAggregatesFilterBase<$PrismaModel>>, 'path'>>
export type JsonNullableWithAggregatesFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_min?: Prisma.NestedJsonNullableFilter<$PrismaModel>
_max?: Prisma.NestedJsonNullableFilter<$PrismaModel>
}
export type NestedStringFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -478,4 +529,28 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
export type NestedJsonNullableFilter<$PrismaModel = never> =
| Prisma.PatchUndefined<
Prisma.Either<Required<NestedJsonNullableFilterBase<$PrismaModel>>, Exclude<keyof Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>,
Required<NestedJsonNullableFilterBase<$PrismaModel>>
>
| Prisma.OptionalFlat<Omit<Required<NestedJsonNullableFilterBase<$PrismaModel>>, 'path'>>
export type NestedJsonNullableFilterBase<$PrismaModel = never> = {
equals?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
path?: string[]
mode?: Prisma.QueryMode | Prisma.EnumQueryModeFieldRefInput<$PrismaModel>
string_contains?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_starts_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
string_ends_with?: string | Prisma.StringFieldRefInput<$PrismaModel>
array_starts_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_ends_with?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
array_contains?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | null
lt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
lte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gt?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
gte?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel>
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
}
File diff suppressed because one or more lines are too long
@@ -398,7 +398,11 @@ export const ModelName = {
ProclamationCategory: 'ProclamationCategory',
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
ColorPalette: 'ColorPalette'
ColorPalette: 'ColorPalette',
AuditLog: 'AuditLog',
Schedule: 'Schedule',
Block: 'Block',
BlockProclamations: 'BlockProclamations'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -414,7 +418,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette"
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette" | "auditLog" | "schedule" | "block" | "blockProclamations"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1528,6 +1532,302 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
AuditLog: {
payload: Prisma.$AuditLogPayload<ExtArgs>
fields: Prisma.AuditLogFieldRefs
operations: {
findUnique: {
args: Prisma.AuditLogFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload> | null
}
findUniqueOrThrow: {
args: Prisma.AuditLogFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
}
findFirst: {
args: Prisma.AuditLogFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload> | null
}
findFirstOrThrow: {
args: Prisma.AuditLogFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
}
findMany: {
args: Prisma.AuditLogFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>[]
}
create: {
args: Prisma.AuditLogCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
}
createMany: {
args: Prisma.AuditLogCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.AuditLogCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>[]
}
delete: {
args: Prisma.AuditLogDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
}
update: {
args: Prisma.AuditLogUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
}
deleteMany: {
args: Prisma.AuditLogDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.AuditLogUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.AuditLogUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>[]
}
upsert: {
args: Prisma.AuditLogUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$AuditLogPayload>
}
aggregate: {
args: Prisma.AuditLogAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateAuditLog>
}
groupBy: {
args: Prisma.AuditLogGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AuditLogGroupByOutputType>[]
}
count: {
args: Prisma.AuditLogCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AuditLogCountAggregateOutputType> | number
}
}
}
Schedule: {
payload: Prisma.$SchedulePayload<ExtArgs>
fields: Prisma.ScheduleFieldRefs
operations: {
findUnique: {
args: Prisma.ScheduleFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload> | null
}
findUniqueOrThrow: {
args: Prisma.ScheduleFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
findFirst: {
args: Prisma.ScheduleFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload> | null
}
findFirstOrThrow: {
args: Prisma.ScheduleFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
findMany: {
args: Prisma.ScheduleFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
create: {
args: Prisma.ScheduleCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
createMany: {
args: Prisma.ScheduleCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ScheduleCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
delete: {
args: Prisma.ScheduleDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
update: {
args: Prisma.ScheduleUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
deleteMany: {
args: Prisma.ScheduleDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ScheduleUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ScheduleUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
upsert: {
args: Prisma.ScheduleUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
aggregate: {
args: Prisma.ScheduleAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSchedule>
}
groupBy: {
args: Prisma.ScheduleGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ScheduleGroupByOutputType>[]
}
count: {
args: Prisma.ScheduleCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ScheduleCountAggregateOutputType> | number
}
}
}
Block: {
payload: Prisma.$BlockPayload<ExtArgs>
fields: Prisma.BlockFieldRefs
operations: {
findUnique: {
args: Prisma.BlockFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload> | null
}
findUniqueOrThrow: {
args: Prisma.BlockFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
findFirst: {
args: Prisma.BlockFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload> | null
}
findFirstOrThrow: {
args: Prisma.BlockFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
findMany: {
args: Prisma.BlockFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
create: {
args: Prisma.BlockCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
createMany: {
args: Prisma.BlockCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.BlockCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
delete: {
args: Prisma.BlockDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
update: {
args: Prisma.BlockUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
deleteMany: {
args: Prisma.BlockDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.BlockUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.BlockUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
upsert: {
args: Prisma.BlockUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
aggregate: {
args: Prisma.BlockAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateBlock>
}
groupBy: {
args: Prisma.BlockGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockGroupByOutputType>[]
}
count: {
args: Prisma.BlockCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockCountAggregateOutputType> | number
}
}
}
BlockProclamations: {
payload: Prisma.$BlockProclamationsPayload<ExtArgs>
fields: Prisma.BlockProclamationsFieldRefs
operations: {
findUnique: {
args: Prisma.BlockProclamationsFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload> | null
}
findUniqueOrThrow: {
args: Prisma.BlockProclamationsFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
findFirst: {
args: Prisma.BlockProclamationsFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload> | null
}
findFirstOrThrow: {
args: Prisma.BlockProclamationsFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
findMany: {
args: Prisma.BlockProclamationsFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
create: {
args: Prisma.BlockProclamationsCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
createMany: {
args: Prisma.BlockProclamationsCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.BlockProclamationsCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
delete: {
args: Prisma.BlockProclamationsDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
update: {
args: Prisma.BlockProclamationsUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
deleteMany: {
args: Prisma.BlockProclamationsDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.BlockProclamationsUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.BlockProclamationsUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
upsert: {
args: Prisma.BlockProclamationsUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
aggregate: {
args: Prisma.BlockProclamationsAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateBlockProclamations>
}
groupBy: {
args: Prisma.BlockProclamationsGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockProclamationsGroupByOutputType>[]
}
count: {
args: Prisma.BlockProclamationsCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockProclamationsCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -1717,6 +2017,8 @@ export const LicenseKeyScalarFieldEnum = {
id: 'id',
key: 'key',
active: 'active',
createdAt: 'createdAt',
lastValidatedAt: 'lastValidatedAt',
userId: 'userId',
email: 'email'
} as const
@@ -1744,6 +2046,59 @@ export const ColorPaletteScalarFieldEnum = {
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof ColorPaletteScalarFieldEnum]
export const AuditLogScalarFieldEnum = {
id: 'id',
createdAt: 'createdAt',
userId: 'userId',
email: 'email',
roleId: 'roleId',
roleName: 'roleName',
page: 'page',
action: 'action',
entity: 'entity',
entityId: 'entityId',
before: 'before',
after: 'after',
ip: 'ip'
} as const
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
export const ScheduleScalarFieldEnum = {
id: 'id',
userId: 'userId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typeof ScheduleScalarFieldEnum]
export const BlockScalarFieldEnum = {
id: 'id',
scheduleId: 'scheduleId',
startMinutes: 'startMinutes',
endMinutes: 'endMinutes',
dynamicAudio: 'dynamicAudio',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockScalarFieldEnum = (typeof BlockScalarFieldEnum)[keyof typeof BlockScalarFieldEnum]
export const BlockProclamationsScalarFieldEnum = {
blockId: 'blockId',
proclamationId: 'proclamationId',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockProclamationsScalarFieldEnum = (typeof BlockProclamationsScalarFieldEnum)[keyof typeof BlockProclamationsScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -1759,6 +2114,14 @@ export const JsonNullValueInput = {
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
export const NullableJsonNullValueInput = {
DbNull: DbNull,
JsonNull: JsonNull
} as const
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
export const QueryMode = {
default: 'default',
insensitive: 'insensitive'
@@ -1991,6 +2354,10 @@ export type GlobalOmitConfig = {
licenseKey?: Prisma.LicenseKeyOmit
apiKey?: Prisma.ApiKeyOmit
colorPalette?: Prisma.ColorPaletteOmit
auditLog?: Prisma.AuditLogOmit
schedule?: Prisma.ScheduleOmit
block?: Prisma.BlockOmit
blockProclamations?: Prisma.BlockProclamationsOmit
}
/* Types for Logging */
@@ -65,7 +65,11 @@ export const ModelName = {
ProclamationCategory: 'ProclamationCategory',
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
ColorPalette: 'ColorPalette'
ColorPalette: 'ColorPalette',
AuditLog: 'AuditLog',
Schedule: 'Schedule',
Block: 'Block',
BlockProclamations: 'BlockProclamations'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -234,6 +238,8 @@ export const LicenseKeyScalarFieldEnum = {
id: 'id',
key: 'key',
active: 'active',
createdAt: 'createdAt',
lastValidatedAt: 'lastValidatedAt',
userId: 'userId',
email: 'email'
} as const
@@ -261,6 +267,59 @@ export const ColorPaletteScalarFieldEnum = {
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof ColorPaletteScalarFieldEnum]
export const AuditLogScalarFieldEnum = {
id: 'id',
createdAt: 'createdAt',
userId: 'userId',
email: 'email',
roleId: 'roleId',
roleName: 'roleName',
page: 'page',
action: 'action',
entity: 'entity',
entityId: 'entityId',
before: 'before',
after: 'after',
ip: 'ip'
} as const
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
export const ScheduleScalarFieldEnum = {
id: 'id',
userId: 'userId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typeof ScheduleScalarFieldEnum]
export const BlockScalarFieldEnum = {
id: 'id',
scheduleId: 'scheduleId',
startMinutes: 'startMinutes',
endMinutes: 'endMinutes',
dynamicAudio: 'dynamicAudio',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockScalarFieldEnum = (typeof BlockScalarFieldEnum)[keyof typeof BlockScalarFieldEnum]
export const BlockProclamationsScalarFieldEnum = {
blockId: 'blockId',
proclamationId: 'proclamationId',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockProclamationsScalarFieldEnum = (typeof BlockProclamationsScalarFieldEnum)[keyof typeof BlockProclamationsScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -276,6 +335,14 @@ export const JsonNullValueInput = {
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof JsonNullValueInput]
export const NullableJsonNullValueInput = {
DbNull: DbNull,
JsonNull: JsonNull
} as const
export type NullableJsonNullValueInput = (typeof NullableJsonNullValueInput)[keyof typeof NullableJsonNullValueInput]
export const QueryMode = {
default: 'default',
insensitive: 'insensitive'
+4
View File
@@ -23,4 +23,8 @@ export type * from './models/ProclamationCategory.ts'
export type * from './models/LicenseKey.ts'
export type * from './models/ApiKey.ts'
export type * from './models/ColorPalette.ts'
export type * from './models/AuditLog.ts'
export type * from './models/Schedule.ts'
export type * from './models/Block.ts'
export type * from './models/BlockProclamations.ts'
export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+71 -1
View File
@@ -28,6 +28,8 @@ export type LicenseKeyMinAggregateOutputType = {
id: string | null
key: string | null
active: boolean | null
createdAt: Date | null
lastValidatedAt: Date | null
userId: string | null
email: string | null
}
@@ -36,6 +38,8 @@ export type LicenseKeyMaxAggregateOutputType = {
id: string | null
key: string | null
active: boolean | null
createdAt: Date | null
lastValidatedAt: Date | null
userId: string | null
email: string | null
}
@@ -44,6 +48,8 @@ export type LicenseKeyCountAggregateOutputType = {
id: number
key: number
active: number
createdAt: number
lastValidatedAt: number
userId: number
email: number
_all: number
@@ -54,6 +60,8 @@ export type LicenseKeyMinAggregateInputType = {
id?: true
key?: true
active?: true
createdAt?: true
lastValidatedAt?: true
userId?: true
email?: true
}
@@ -62,6 +70,8 @@ export type LicenseKeyMaxAggregateInputType = {
id?: true
key?: true
active?: true
createdAt?: true
lastValidatedAt?: true
userId?: true
email?: true
}
@@ -70,6 +80,8 @@ export type LicenseKeyCountAggregateInputType = {
id?: true
key?: true
active?: true
createdAt?: true
lastValidatedAt?: true
userId?: true
email?: true
_all?: true
@@ -151,6 +163,8 @@ export type LicenseKeyGroupByOutputType = {
id: string
key: string
active: boolean
createdAt: Date
lastValidatedAt: Date | null
userId: string | null
email: string | null
_count: LicenseKeyCountAggregateOutputType | null
@@ -180,6 +194,8 @@ export type LicenseKeyWhereInput = {
id?: Prisma.StringFilter<"LicenseKey"> | string
key?: Prisma.StringFilter<"LicenseKey"> | string
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
createdAt?: Prisma.DateTimeFilter<"LicenseKey"> | Date | string
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
@@ -189,6 +205,8 @@ export type LicenseKeyOrderByWithRelationInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
active?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder
email?: Prisma.SortOrderInput | Prisma.SortOrder
user?: Prisma.UserOrderByWithRelationInput
@@ -201,6 +219,8 @@ export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{
OR?: Prisma.LicenseKeyWhereInput[]
NOT?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
createdAt?: Prisma.DateTimeFilter<"LicenseKey"> | Date | string
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
@@ -210,6 +230,8 @@ export type LicenseKeyOrderByWithAggregationInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
active?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder
email?: Prisma.SortOrderInput | Prisma.SortOrder
_count?: Prisma.LicenseKeyCountOrderByAggregateInput
@@ -224,6 +246,8 @@ export type LicenseKeyScalarWhereWithAggregatesInput = {
id?: Prisma.StringWithAggregatesFilter<"LicenseKey"> | string
key?: Prisma.StringWithAggregatesFilter<"LicenseKey"> | string
active?: Prisma.BoolWithAggregatesFilter<"LicenseKey"> | boolean
createdAt?: Prisma.DateTimeWithAggregatesFilter<"LicenseKey"> | Date | string
lastValidatedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"LicenseKey"> | Date | string | null
userId?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
}
@@ -232,6 +256,8 @@ export type LicenseKeyCreateInput = {
id?: string
key: string
active: boolean
createdAt?: Date | string
lastValidatedAt?: Date | string | null
email?: string | null
user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
}
@@ -240,6 +266,8 @@ export type LicenseKeyUncheckedCreateInput = {
id?: string
key: string
active: boolean
createdAt?: Date | string
lastValidatedAt?: Date | string | null
userId?: string | null
email?: string | null
}
@@ -248,6 +276,8 @@ export type LicenseKeyUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
user?: Prisma.UserUpdateOneWithoutLicenseKeysNestedInput
}
@@ -256,6 +286,8 @@ export type LicenseKeyUncheckedUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
@@ -264,6 +296,8 @@ export type LicenseKeyCreateManyInput = {
id?: string
key: string
active: boolean
createdAt?: Date | string
lastValidatedAt?: Date | string | null
userId?: string | null
email?: string | null
}
@@ -272,6 +306,8 @@ export type LicenseKeyUpdateManyMutationInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
@@ -279,6 +315,8 @@ export type LicenseKeyUncheckedUpdateManyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
@@ -297,6 +335,8 @@ export type LicenseKeyCountOrderByAggregateInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
active?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
lastValidatedAt?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
}
@@ -305,6 +345,8 @@ export type LicenseKeyMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
active?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
lastValidatedAt?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
}
@@ -313,6 +355,8 @@ export type LicenseKeyMinOrderByAggregateInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
active?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
lastValidatedAt?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
}
@@ -363,6 +407,8 @@ export type LicenseKeyCreateWithoutUserInput = {
id?: string
key: string
active: boolean
createdAt?: Date | string
lastValidatedAt?: Date | string | null
email?: string | null
}
@@ -370,6 +416,8 @@ export type LicenseKeyUncheckedCreateWithoutUserInput = {
id?: string
key: string
active: boolean
createdAt?: Date | string
lastValidatedAt?: Date | string | null
email?: string | null
}
@@ -406,6 +454,8 @@ export type LicenseKeyScalarWhereInput = {
id?: Prisma.StringFilter<"LicenseKey"> | string
key?: Prisma.StringFilter<"LicenseKey"> | string
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
createdAt?: Prisma.DateTimeFilter<"LicenseKey"> | Date | string
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
}
@@ -414,6 +464,8 @@ export type LicenseKeyCreateManyUserInput = {
id?: string
key: string
active: boolean
createdAt?: Date | string
lastValidatedAt?: Date | string | null
email?: string | null
}
@@ -421,6 +473,8 @@ export type LicenseKeyUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
@@ -428,6 +482,8 @@ export type LicenseKeyUncheckedUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
@@ -435,6 +491,8 @@ export type LicenseKeyUncheckedUpdateManyWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
@@ -444,6 +502,8 @@ export type LicenseKeySelect<ExtArgs extends runtime.Types.Extensions.InternalAr
id?: boolean
key?: boolean
active?: boolean
createdAt?: boolean
lastValidatedAt?: boolean
userId?: boolean
email?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
@@ -453,6 +513,8 @@ export type LicenseKeySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Ex
id?: boolean
key?: boolean
active?: boolean
createdAt?: boolean
lastValidatedAt?: boolean
userId?: boolean
email?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
@@ -462,6 +524,8 @@ export type LicenseKeySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Ex
id?: boolean
key?: boolean
active?: boolean
createdAt?: boolean
lastValidatedAt?: boolean
userId?: boolean
email?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
@@ -471,11 +535,13 @@ export type LicenseKeySelectScalar = {
id?: boolean
key?: boolean
active?: boolean
createdAt?: boolean
lastValidatedAt?: boolean
userId?: boolean
email?: boolean
}
export type LicenseKeyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "key" | "active" | "userId" | "email", ExtArgs["result"]["licenseKey"]>
export type LicenseKeyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "key" | "active" | "createdAt" | "lastValidatedAt" | "userId" | "email", ExtArgs["result"]["licenseKey"]>
export type LicenseKeyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}
@@ -495,6 +561,8 @@ export type $LicenseKeyPayload<ExtArgs extends runtime.Types.Extensions.Internal
id: string
key: string
active: boolean
createdAt: Date
lastValidatedAt: Date | null
userId: string | null
email: string | null
}, ExtArgs["result"]["licenseKey"]>
@@ -924,6 +992,8 @@ export interface LicenseKeyFieldRefs {
readonly id: Prisma.FieldRef<"LicenseKey", 'String'>
readonly key: Prisma.FieldRef<"LicenseKey", 'String'>
readonly active: Prisma.FieldRef<"LicenseKey", 'Boolean'>
readonly createdAt: Prisma.FieldRef<"LicenseKey", 'DateTime'>
readonly lastValidatedAt: Prisma.FieldRef<"LicenseKey", 'DateTime'>
readonly userId: Prisma.FieldRef<"LicenseKey", 'String'>
readonly email: Prisma.FieldRef<"LicenseKey", 'String'>
}
@@ -236,6 +236,7 @@ export type ProclamationWhereInput = {
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
userProclamations?: Prisma.UserProclamationsListRelationFilter
blockProclamations?: Prisma.BlockProclamationsListRelationFilter
}
export type ProclamationOrderByWithRelationInput = {
@@ -249,6 +250,7 @@ export type ProclamationOrderByWithRelationInput = {
category?: Prisma.ProclamationCategoryOrderByWithRelationInput
group?: Prisma.ProclamationGroupOrderByWithRelationInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
blockProclamations?: Prisma.BlockProclamationsOrderByRelationAggregateInput
}
export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
@@ -265,6 +267,7 @@ export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
userProclamations?: Prisma.UserProclamationsListRelationFilter
blockProclamations?: Prisma.BlockProclamationsListRelationFilter
}, "id" | "name">
export type ProclamationOrderByWithAggregationInput = {
@@ -301,6 +304,7 @@ export type ProclamationCreateInput = {
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateInput = {
@@ -311,6 +315,7 @@ export type ProclamationUncheckedCreateInput = {
categoryId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationUpdateInput = {
@@ -321,6 +326,7 @@ export type ProclamationUpdateInput = {
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateInput = {
@@ -331,6 +337,7 @@ export type ProclamationUncheckedUpdateInput = {
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationCreateManyInput = {
@@ -551,6 +558,20 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryNestedInput = {
deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
}
export type ProclamationCreateNestedOneWithoutBlockProclamationsInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutBlockProclamationsInput
connect?: Prisma.ProclamationWhereUniqueInput
}
export type ProclamationUpdateOneRequiredWithoutBlockProclamationsNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutBlockProclamationsInput
upsert?: Prisma.ProclamationUpsertWithoutBlockProclamationsInput
connect?: Prisma.ProclamationWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ProclamationUpdateToOneWithWhereWithoutBlockProclamationsInput, Prisma.ProclamationUpdateWithoutBlockProclamationsInput>, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
}
export type ProclamationCreateWithoutGroupInput = {
id?: string
name: string
@@ -558,6 +579,7 @@ export type ProclamationCreateWithoutGroupInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutGroupInput = {
@@ -567,6 +589,7 @@ export type ProclamationUncheckedCreateWithoutGroupInput = {
solfeggioFrequencyId: number
categoryId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutGroupInput = {
@@ -614,6 +637,7 @@ export type ProclamationCreateWithoutUserProclamationsInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
@@ -623,6 +647,7 @@ export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
solfeggioFrequencyId: number
categoryId: number
groupId: number
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutUserProclamationsInput = {
@@ -648,6 +673,7 @@ export type ProclamationUpdateWithoutUserProclamationsInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
@@ -657,6 +683,7 @@ export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
@@ -666,6 +693,7 @@ export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
@@ -675,6 +703,7 @@ export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
categoryId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutSolfeggioFrequencyInput = {
@@ -710,6 +739,7 @@ export type ProclamationCreateWithoutCategoryInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutCategoryInput = {
@@ -719,6 +749,7 @@ export type ProclamationUncheckedCreateWithoutCategoryInput = {
solfeggioFrequencyId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutCategoryInput = {
@@ -747,6 +778,62 @@ export type ProclamationUpdateManyWithWhereWithoutCategoryInput = {
data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutCategoryInput>
}
export type ProclamationCreateWithoutBlockProclamationsInput = {
id?: string
name: string
content: string
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutBlockProclamationsInput = {
id?: string
name: string
content: string
solfeggioFrequencyId: number
categoryId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutBlockProclamationsInput = {
where: Prisma.ProclamationWhereUniqueInput
create: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
}
export type ProclamationUpsertWithoutBlockProclamationsInput = {
update: Prisma.XOR<Prisma.ProclamationUpdateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
create: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
where?: Prisma.ProclamationWhereInput
}
export type ProclamationUpdateToOneWithWhereWithoutBlockProclamationsInput = {
where?: Prisma.ProclamationWhereInput
data: Prisma.XOR<Prisma.ProclamationUpdateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
}
export type ProclamationUpdateWithoutBlockProclamationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutBlockProclamationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationCreateManyGroupInput = {
id?: string
name: string
@@ -762,6 +849,7 @@ export type ProclamationUpdateWithoutGroupInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutGroupInput = {
@@ -771,6 +859,7 @@ export type ProclamationUncheckedUpdateWithoutGroupInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateManyWithoutGroupInput = {
@@ -796,6 +885,7 @@ export type ProclamationUpdateWithoutSolfeggioFrequencyInput = {
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
@@ -805,6 +895,7 @@ export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyInput = {
@@ -830,6 +921,7 @@ export type ProclamationUpdateWithoutCategoryInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutCategoryInput = {
@@ -839,6 +931,7 @@ export type ProclamationUncheckedUpdateWithoutCategoryInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
@@ -856,10 +949,12 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
export type ProclamationCountOutputType = {
userProclamations: number
blockProclamations: number
}
export type ProclamationCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
userProclamations?: boolean | ProclamationCountOutputTypeCountUserProclamationsArgs
blockProclamations?: boolean | ProclamationCountOutputTypeCountBlockProclamationsArgs
}
/**
@@ -879,6 +974,13 @@ export type ProclamationCountOutputTypeCountUserProclamationsArgs<ExtArgs extend
where?: Prisma.UserProclamationsWhereInput
}
/**
* ProclamationCountOutputType without action
*/
export type ProclamationCountOutputTypeCountBlockProclamationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.BlockProclamationsWhereInput
}
export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
@@ -891,6 +993,7 @@ export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.Internal
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
blockProclamations?: boolean | Prisma.Proclamation$blockProclamationsArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["proclamation"]>
@@ -933,6 +1036,7 @@ export type ProclamationInclude<ExtArgs extends runtime.Types.Extensions.Interna
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
blockProclamations?: boolean | Prisma.Proclamation$blockProclamationsArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
}
export type ProclamationIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -953,6 +1057,7 @@ export type $ProclamationPayload<ExtArgs extends runtime.Types.Extensions.Intern
category: Prisma.$ProclamationCategoryPayload<ExtArgs>
group: Prisma.$ProclamationGroupPayload<ExtArgs>
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
blockProclamations: Prisma.$BlockProclamationsPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -1359,6 +1464,7 @@ export interface Prisma__ProclamationClient<T, Null = never, ExtArgs extends run
category<T extends Prisma.ProclamationCategoryDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationCategoryDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationCategoryClient<runtime.Types.Result.GetResult<Prisma.$ProclamationCategoryPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
group<T extends Prisma.ProclamationGroupDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationGroupDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationGroupClient<runtime.Types.Result.GetResult<Prisma.$ProclamationGroupPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
userProclamations<T extends Prisma.Proclamation$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Proclamation$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
blockProclamations<T extends Prisma.Proclamation$blockProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Proclamation$blockProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$BlockProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1818,6 +1924,30 @@ export type Proclamation$userProclamationsArgs<ExtArgs extends runtime.Types.Ext
distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[]
}
/**
* Proclamation.blockProclamations
*/
export type Proclamation$blockProclamationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the BlockProclamations
*/
select?: Prisma.BlockProclamationsSelect<ExtArgs> | null
/**
* Omit specific fields from the BlockProclamations
*/
omit?: Prisma.BlockProclamationsOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.BlockProclamationsInclude<ExtArgs> | null
where?: Prisma.BlockProclamationsWhereInput
orderBy?: Prisma.BlockProclamationsOrderByWithRelationInput | Prisma.BlockProclamationsOrderByWithRelationInput[]
cursor?: Prisma.BlockProclamationsWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.BlockProclamationsScalarFieldEnum | Prisma.BlockProclamationsScalarFieldEnum[]
}
/**
* Proclamation without action
*/
File diff suppressed because it is too large Load Diff
+148
View File
@@ -246,6 +246,7 @@ export type UserWhereInput = {
profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
}
export type UserOrderByWithRelationInput = {
@@ -263,6 +264,7 @@ export type UserOrderByWithRelationInput = {
profiles?: Prisma.ProfileOrderByRelationAggregateInput
licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
schedule?: Prisma.ScheduleOrderByWithRelationInput
}
export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -283,6 +285,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
}, "id" | "email">
export type UserOrderByWithAggregationInput = {
@@ -329,6 +332,7 @@ export type UserCreateInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateInput = {
@@ -345,6 +349,7 @@ export type UserUncheckedCreateInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserUpdateInput = {
@@ -361,6 +366,7 @@ export type UserUpdateInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateInput = {
@@ -377,6 +383,7 @@ export type UserUncheckedUpdateInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateManyInput = {
@@ -612,6 +619,20 @@ export type UserUpdateOneWithoutLicenseKeysNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput>
}
export type UserCreateNestedOneWithoutScheduleInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutScheduleNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
upsert?: Prisma.UserUpsertWithoutScheduleInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutScheduleInput, Prisma.UserUpdateWithoutScheduleInput>, Prisma.UserUncheckedUpdateWithoutScheduleInput>
}
export type UserCreateWithoutProfilesInput = {
id: string
name: string
@@ -625,6 +646,7 @@ export type UserCreateWithoutProfilesInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutProfilesInput = {
@@ -640,6 +662,7 @@ export type UserUncheckedCreateWithoutProfilesInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutProfilesInput = {
@@ -671,6 +694,7 @@ export type UserUpdateWithoutProfilesInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutProfilesInput = {
@@ -686,6 +710,7 @@ export type UserUncheckedUpdateWithoutProfilesInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutSessionsInput = {
@@ -701,6 +726,7 @@ export type UserCreateWithoutSessionsInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutSessionsInput = {
@@ -716,6 +742,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutSessionsInput = {
@@ -747,6 +774,7 @@ export type UserUpdateWithoutSessionsInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutSessionsInput = {
@@ -762,6 +790,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutAccountsInput = {
@@ -777,6 +806,7 @@ export type UserCreateWithoutAccountsInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutAccountsInput = {
@@ -792,6 +822,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutAccountsInput = {
@@ -823,6 +854,7 @@ export type UserUpdateWithoutAccountsInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutAccountsInput = {
@@ -838,6 +870,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutRoleInput = {
@@ -853,6 +886,7 @@ export type UserCreateWithoutRoleInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutRoleInput = {
@@ -868,6 +902,7 @@ export type UserUncheckedCreateWithoutRoleInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutRoleInput = {
@@ -923,6 +958,7 @@ export type UserCreateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutUserProclamationsInput = {
@@ -938,6 +974,7 @@ export type UserUncheckedCreateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutUserProclamationsInput = {
@@ -969,6 +1006,7 @@ export type UserUpdateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutUserProclamationsInput = {
@@ -984,6 +1022,7 @@ export type UserUncheckedUpdateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutLicenseKeysInput = {
@@ -999,6 +1038,7 @@ export type UserCreateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
}
export type UserUncheckedCreateWithoutLicenseKeysInput = {
@@ -1014,6 +1054,7 @@ export type UserUncheckedCreateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
}
export type UserCreateOrConnectWithoutLicenseKeysInput = {
@@ -1045,6 +1086,7 @@ export type UserUpdateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutLicenseKeysInput = {
@@ -1060,6 +1102,87 @@ export type UserUncheckedUpdateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutScheduleInput = {
id: string
name: string
email: string
emailVerified?: boolean
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
role?: Prisma.UserRoleCreateNestedOneWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutScheduleInput = {
id: string
name: string
email: string
emailVerified?: boolean
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
roleId?: number | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutScheduleInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
}
export type UserUpsertWithoutScheduleInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutScheduleInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
}
export type UserUpdateWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.UserRoleUpdateOneWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roleId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
}
export type UserCreateManyRoleInput = {
@@ -1085,6 +1208,7 @@ export type UserUpdateWithoutRoleInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutRoleInput = {
@@ -1100,6 +1224,7 @@ export type UserUncheckedUpdateWithoutRoleInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserUncheckedUpdateManyWithoutRoleInput = {
@@ -1194,6 +1319,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]>
@@ -1240,6 +1366,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1258,6 +1385,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
profiles: Prisma.$ProfilePayload<ExtArgs>[]
licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[]
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
schedule: Prisma.$SchedulePayload<ExtArgs> | null
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -1668,6 +1796,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
profiles<T extends Prisma.User$profilesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profilesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProfilePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
licenseKeys<T extends Prisma.User$licenseKeysArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$licenseKeysArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
userProclamations<T extends Prisma.User$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
schedule<T extends Prisma.User$scheduleArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$scheduleArgs<ExtArgs>>): Prisma.Prisma__ScheduleClient<runtime.Types.Result.GetResult<Prisma.$SchedulePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -2244,6 +2373,25 @@ export type User$userProclamationsArgs<ExtArgs extends runtime.Types.Extensions.
distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[]
}
/**
* User.schedule
*/
export type User$scheduleArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Schedule
*/
select?: Prisma.ScheduleSelect<ExtArgs> | null
/**
* Omit specific fields from the Schedule
*/
omit?: Prisma.ScheduleOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ScheduleInclude<ExtArgs> | null
where?: Prisma.ScheduleWhereInput
}
/**
* User without action
*/
+235
View File
@@ -0,0 +1,235 @@
import { describe, it, expect } from 'vitest';
import {
blockDuration,
blockSegments,
findOverlap,
formatTime,
isOvernight,
snapMinutes,
toHm,
validateBlock,
type BlockInput,
type BlockSibling
} from './schedule-utils';
const input = (overrides: Partial<BlockInput> = {}): BlockInput => ({
startMinutes: 6 * 60,
endMinutes: 8 * 60,
proclamationIds: ['p1'],
...overrides
});
const sibling = (startMinutes: number, endMinutes: number, id = 'b1'): BlockSibling => ({
id,
startMinutes,
endMinutes
});
describe('formatTime', () => {
it('formats 12-hour clock labels', () => {
expect(formatTime(0)).toBe('12:00 AM');
expect(formatTime(6 * 60)).toBe('6:00 AM');
expect(formatTime(8 * 60 + 5)).toBe('8:05 AM');
expect(formatTime(12 * 60)).toBe('12:00 PM');
expect(formatTime(13 * 60 + 30)).toBe('1:30 PM');
expect(formatTime(23 * 60 + 59)).toBe('11:59 PM');
});
});
describe('toHm', () => {
it('formats 24-hour HH:MM strings', () => {
expect(toHm(0)).toBe('00:00');
expect(toHm(6 * 60)).toBe('06:00');
expect(toHm(23 * 60 + 59)).toBe('23:59');
});
});
describe('snapMinutes', () => {
it('rounds to the nearest 15 minutes and clamps to the day', () => {
expect(snapMinutes(0)).toBe(0);
expect(snapMinutes(7)).toBe(0);
expect(snapMinutes(8)).toBe(15);
expect(snapMinutes(370)).toBe(375);
expect(snapMinutes(-10)).toBe(0);
expect(snapMinutes(2000)).toBe(1440);
});
});
describe('isOvernight', () => {
it('is true when the end clock time is before the start', () => {
expect(isOvernight({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(true);
expect(isOvernight({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(true);
});
it('is false for same-day ranges and a full-day range', () => {
expect(isOvernight({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(false);
expect(isOvernight({ startMinutes: 0, endMinutes: 1440 })).toBe(false);
});
});
describe('blockDuration', () => {
it('counts a same-day range directly', () => {
expect(blockDuration({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(120);
});
it('counts an overnight range as wrapping to the next day', () => {
expect(blockDuration({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(720);
});
it('treats an end of midnight as wrapping from the start', () => {
expect(blockDuration({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(120);
});
});
describe('blockSegments', () => {
it('returns a single segment for a same-day range', () => {
expect(blockSegments({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toEqual([[6 * 60, 8 * 60]]);
});
it('splits an overnight range at midnight', () => {
expect(blockSegments({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toEqual([
[21 * 60, 1440],
[0, 9 * 60]
]);
});
it('returns no segments for a zero-length range', () => {
expect(blockSegments({ startMinutes: 360, endMinutes: 360 })).toEqual([]);
});
});
describe('findOverlap', () => {
it('returns null for adjacent ranges', () => {
expect(
findOverlap({ startMinutes: 8 * 60, endMinutes: 9 * 60 }, [sibling(6 * 60, 8 * 60)])
).toBeNull();
});
it('returns the sibling for a partial overlap', () => {
const other = sibling(7 * 60, 9 * 60);
expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other);
});
it('detects an overnight block overlapping an early-morning block', () => {
const other = sibling(8 * 60, 10 * 60);
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
});
it('detects an overnight block overlapping a late-evening block', () => {
const other = sibling(20 * 60, 22 * 60);
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
});
it('allows an overnight block and its exact complement (adjacent at both ends)', () => {
expect(
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(9 * 60, 21 * 60)])
).toBeNull();
});
it('allows an overnight block and a midday block it does not touch', () => {
expect(
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(10 * 60, 20 * 60)])
).toBeNull();
});
});
describe('validateBlock', () => {
it('accepts a valid block with no siblings', () => {
expect(validateBlock(input(), [], { isCreate: true })).toEqual({ ok: true });
});
it('rejects equal start and end times', () => {
const result = validateBlock(input({ endMinutes: 6 * 60 }), []);
expect(result).toEqual({
ok: false,
error: "Start and end time can't be the same."
});
});
it('rejects a time outside the 24-hour range', () => {
expect(validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), [])).toEqual({
ok: false,
error: 'Enter a valid start and end time.'
});
});
it('rejects a negative start time', () => {
expect(validateBlock(input({ startMinutes: -1 }), [])).toEqual({
ok: false,
error: 'Enter a valid start and end time.'
});
});
it('accepts an overnight block', () => {
expect(
validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [], {
isCreate: true
})
).toEqual({ ok: true });
});
it('rejects an overnight block that overlaps another and names it', () => {
const result = validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [
sibling(8 * 60, 10 * 60)
]);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 8:00 AM–10:00 AM block.'
});
});
it('requires at least one proclamation', () => {
expect(validateBlock(input({ proclamationIds: [] }), [])).toEqual({
ok: false,
error: 'Select at least one proclamation.'
});
});
it('enforces the four-block maximum on create', () => {
const siblings = [
sibling(0, 60, 'a'),
sibling(60, 120, 'b'),
sibling(120, 180, 'c'),
sibling(180, 240, 'd')
];
const result = validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings, {
isCreate: true
});
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
});
it('allows adjacent blocks (end exactly equals next start)', () => {
expect(
validateBlock(input({ startMinutes: 8 * 60, endMinutes: 9 * 60 }), [sibling(6 * 60, 8 * 60)])
).toEqual({
ok: true
});
});
it('rejects an exact duplicate range and names the conflicting block', () => {
const result = validateBlock(input(), [sibling(6 * 60, 8 * 60)]);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 6:00 AM–8:00 AM block.'
});
});
it('rejects a partial overlap', () => {
const result = validateBlock(input({ startMinutes: 7 * 60, endMinutes: 9 * 60 }), [
sibling(8 * 60, 10 * 60)
]);
expect(result.ok).toBe(false);
});
it('rejects a block that contains another block', () => {
const result = validateBlock(input({ startMinutes: 5 * 60, endMinutes: 11 * 60 }), [
sibling(6 * 60, 8 * 60)
]);
expect(result.ok).toBe(false);
});
it('does not apply the maximum when updating (isCreate false)', () => {
const siblings = [sibling(0, 60, 'a'), sibling(60, 120, 'b'), sibling(120, 180, 'c')];
expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true);
});
});
+161
View File
@@ -0,0 +1,161 @@
// Isomorphic, dependency-free schedule helpers. Used by the server service
// layer, the v2 serializers and the client-side Block modal (for live
// pre-validation), so it must not import Prisma or any server-only code.
export const MAX_BLOCKS = 4;
export const MINUTES_PER_DAY = 24 * 60;
export const SNAP_MINUTES = 15;
export const MINUTE_STEP = 5;
export type BlockSibling = {
id?: string;
startMinutes: number;
endMinutes: number;
};
export type BlockInput = {
startMinutes: number;
endMinutes: number;
proclamationIds: string[];
/** When true the kiosk enables dynamic audio for the block's duration. */
dynamicAudio?: boolean;
};
export type ValidationResult = { ok: true } | { ok: false; error: string };
function normalizeMinutes(minutes: number): number {
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
}
/** Format minutes-from-midnight as a 12-hour clock label, e.g. `6:00 AM`. */
export function formatTime(minutes: number): string {
const normalized = normalizeMinutes(minutes);
const hours24 = Math.floor(normalized / 60);
const mins = normalized % 60;
const period = hours24 < 12 ? 'AM' : 'PM';
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
return `${hours12}:${String(mins).padStart(2, '0')} ${period}`;
}
/** Format minutes-from-midnight as a 24-hour `HH:MM` string. */
export function toHm(minutes: number): string {
const normalized = normalizeMinutes(minutes);
const hours = Math.floor(normalized / 60);
const mins = normalized % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */
export function snapMinutes(minutes: number): number {
const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapped));
}
/** A Block whose end clock time is at or before its start wraps past midnight. */
export function isOvernight(block: { startMinutes: number; endMinutes: number }): boolean {
return block.endMinutes <= block.startMinutes;
}
/** Block length in minutes, counting a wrap past midnight as the next day. */
export function blockDuration(block: { startMinutes: number; endMinutes: number }): number {
if (block.endMinutes > block.startMinutes) {
return block.endMinutes - block.startMinutes;
}
return block.endMinutes - block.startMinutes + MINUTES_PER_DAY;
}
/**
* Split a Block into the linear half-open `[start, end)` segments it occupies
* within the 24h day. An overnight block produces two segments (start→midnight
* and midnight→end); a zero-length block produces none.
*/
export function blockSegments(block: {
startMinutes: number;
endMinutes: number;
}): [number, number][] {
if (block.startMinutes < block.endMinutes) {
return [[block.startMinutes, block.endMinutes]];
}
if (block.startMinutes > block.endMinutes) {
return [
[block.startMinutes, MINUTES_PER_DAY],
[0, block.endMinutes]
];
}
return [];
}
function segmentsIntersect(a: [number, number], b: [number, number]): boolean {
return a[0] < b[1] && b[0] < a[1];
}
/**
* First sibling whose occupied time intersects the input. Works for overnight
* (midnight-crossing) blocks by comparing their linear segments; ranges that
* only touch at an endpoint (e.g. 9PM–9AM vs 9AM–9PM) do not overlap.
*/
export function findOverlap(
input: { startMinutes: number; endMinutes: number },
siblings: BlockSibling[]
): BlockSibling | null {
const inputSegments = blockSegments(input);
for (const sibling of siblings) {
const siblingSegments = blockSegments(sibling);
for (const segment of inputSegments) {
for (const other of siblingSegments) {
if (segmentsIntersect(segment, other)) return sibling;
}
}
}
return null;
}
/**
* Validate a Block against the schedule's other Blocks. `siblings` must exclude
* the Block being edited. `isCreate` enables the 4-block maximum (an update
* never increases the count).
*/
export function validateBlock(
input: BlockInput,
siblings: BlockSibling[],
opts: { isCreate?: boolean } = {}
): ValidationResult {
const { startMinutes, endMinutes, proclamationIds } = input;
if (
!Number.isInteger(startMinutes) ||
!Number.isInteger(endMinutes) ||
startMinutes < 0 ||
startMinutes > MINUTES_PER_DAY ||
endMinutes < 0 ||
endMinutes > MINUTES_PER_DAY
) {
return { ok: false, error: 'Enter a valid start and end time.' };
}
// Equal clock times would be a zero- or full-length block; both are
// ambiguous, so reject them. Overnight ranges (end before start) are valid.
if (startMinutes === endMinutes) {
return { ok: false, error: "Start and end time can't be the same." };
}
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
return { ok: false, error: 'Select at least one proclamation.' };
}
if (opts.isCreate && siblings.length >= MAX_BLOCKS) {
return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks.` };
}
const overlap = findOverlap(input, siblings);
if (overlap) {
return {
ok: false,
error: `This overlaps with your ${formatTime(overlap.startMinutes)}–${formatTime(
overlap.endMinutes
)} block.`
};
}
return { ok: true };
}
+23
View File
@@ -0,0 +1,23 @@
import { AsyncLocalStorage } from 'node:async_hooks';
/**
* The actor for the current request, used by the Prisma audit extension to
* attribute database mutations. Populated in `hooks.server.ts`.
*/
export type AuditContext = {
userId: string | null;
email: string | null;
roleId: number | null;
page: string;
ip: string | null;
};
const storage = new AsyncLocalStorage<AuditContext>();
export function runWithAuditContext<T>(context: AuditContext, fn: () => T): T {
return storage.run(context, fn);
}
export function getAuditContext(): AuditContext | undefined {
return storage.getStore();
}
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { describeAction, entityIdFor, sanitizeAuditValue, shouldAudit } from './audit';
describe('shouldAudit', () => {
it('audits mutations', () => {
expect(shouldAudit('User', 'create')).toBe(true);
expect(shouldAudit('Profile', 'update')).toBe(true);
expect(shouldAudit('Proclamation', 'delete')).toBe(true);
expect(shouldAudit('LicenseKey', 'updateMany')).toBe(true);
});
it('ignores reads', () => {
expect(shouldAudit('User', 'findMany')).toBe(false);
expect(shouldAudit('User', 'findUnique')).toBe(false);
expect(shouldAudit('User', 'count')).toBe(false);
});
it('ignores the audit log and auth internals', () => {
for (const model of ['AuditLog', 'Session', 'Account', 'Verification']) {
expect(shouldAudit(model, 'create')).toBe(false);
}
});
});
describe('sanitizeAuditValue', () => {
it('redacts sensitive keys', () => {
const out = sanitizeAuditValue({
password: 'hunter2',
token: 'abc',
keyHash: 'deadbeef',
email: 'jane@example.com'
}) as Record<string, unknown>;
expect(out.password).toBe('[redacted]');
expect(out.token).toBe('[redacted]');
expect(out.keyHash).toBe('[redacted]');
expect(out.email).toBe('jane@example.com');
});
it('converts dates to ISO strings', () => {
const date = new Date('2026-01-01T00:00:00.000Z');
expect(sanitizeAuditValue({ at: date })).toEqual({ at: '2026-01-01T00:00:00.000Z' });
});
it('handles arrays and nullish values', () => {
expect(sanitizeAuditValue([1, null, 'a'])).toEqual([1, null, 'a']);
expect(sanitizeAuditValue(null)).toBeNull();
expect(sanitizeAuditValue(undefined)).toBeNull();
});
it('truncates very long strings', () => {
const long = 'a'.repeat(21000);
const out = sanitizeAuditValue(long) as string;
expect(out.length).toBe(20001);
expect(out.endsWith('…')).toBe(true);
});
});
describe('entityIdFor', () => {
it('prefers the result id', () => {
expect(entityIdFor({ where: { id: 'w' } }, { id: 'r' }, { id: 'b' })).toBe('r');
});
it('falls back to where, data, then before', () => {
expect(entityIdFor({ where: { id: 5 } }, null, null)).toBe('5');
expect(entityIdFor({ data: { id: 'd' } }, null, null)).toBe('d');
expect(entityIdFor({}, null, { id: 'b' })).toBe('b');
expect(entityIdFor({}, null, null)).toBeNull();
});
});
describe('describeAction', () => {
it('labels bulk operations', () => {
expect(describeAction('updateMany')).toBe('bulk update');
expect(describeAction('deleteMany')).toBe('bulk delete');
expect(describeAction('create')).toBe('create');
});
});
+87
View File
@@ -0,0 +1,87 @@
/**
* Pure helpers for the audit extension. Kept free of database imports so the
* extension in `$lib/db.ts` can use them without a cycle.
*/
/** Prisma operations that change data. Reads are never audited. */
const MUTATION_OPERATIONS = new Set([
'create',
'createMany',
'createManyAndReturn',
'update',
'updateMany',
'updateManyAndReturn',
'upsert',
'delete',
'deleteMany'
]);
/**
* Models we never audit:
* - `AuditLog` itself (would recurse and is not user data)
* - `Session`/`Account`/`Verification` are high-churn auth internals that hold
* tokens, password hashes and other secrets. Business mutations are audited.
*/
const EXCLUDED_MODELS = new Set(['AuditLog', 'Session', 'Account', 'Verification']);
export function shouldAudit(model: string, operation: string): boolean {
return !EXCLUDED_MODELS.has(model) && MUTATION_OPERATIONS.has(operation);
}
const SENSITIVE_KEY = /pass(word)?|secret|token|hash/i;
/**
* Make a value safe to persist as JSON: dates become ISO strings, sensitive
* keys are redacted, very long strings are truncated, and cycles/depth are
* bounded.
*/
export function sanitizeAuditValue(value: unknown, depth = 0): unknown {
if (value === undefined || value === null) return null;
if (depth > 8) return '[truncated]';
if (value instanceof Date) return value.toISOString();
if (Array.isArray(value)) return value.map((item) => sanitizeAuditValue(item, depth + 1));
if (typeof value === 'object') {
const out: Record<string, unknown> = {};
for (const [key, nested] of Object.entries(value as Record<string, unknown>)) {
out[key] = SENSITIVE_KEY.test(key) ? '[redacted]' : sanitizeAuditValue(nested, depth + 1);
}
return out;
}
if (typeof value === 'string' && value.length > 20000) return `${value.slice(0, 20000)}…`;
return value;
}
/** Best-effort row id for the changed record. */
export function entityIdFor(args: unknown, result: unknown, before: unknown): string | null {
const where = (args as { where?: { id?: unknown } } | null)?.where;
const data = (args as { data?: { id?: unknown } } | null)?.data;
const resultId = (result as { id?: unknown } | null)?.id;
const first = Array.isArray(before) ? before[0] : before;
const beforeId = (first as { id?: unknown } | undefined)?.id;
const id = resultId ?? where?.id ?? data?.id ?? beforeId;
return id === undefined || id === null ? null : String(id);
}
/** Human label for an operation, e.g. `updateMany` → `bulk update`. */
export function describeAction(operation: string): string {
switch (operation) {
case 'create':
return 'create';
case 'update':
return 'update';
case 'delete':
return 'delete';
case 'upsert':
return 'upsert';
case 'createMany':
case 'createManyAndReturn':
return 'bulk create';
case 'updateMany':
case 'updateManyAndReturn':
return 'bulk update';
case 'deleteMany':
return 'bulk delete';
default:
return operation;
}
}
+26
View File
@@ -0,0 +1,26 @@
import { env } from '$env/dynamic/private';
import { sendMail } from './resend';
/**
* Send the "finish your registration" email to a user created by an admin.
*
* The link points at Better Auth's reset-password callback, which redirects to
* `/reset-password?invite=1&token=...`. There the user sets a password, then
* logs in and is sent to `/profile/setup` to complete their profile.
*
* Uses a Resend-hosted template (`registration-email` by default, or
* `RESEND_REGISTRATION_TEMPLATE_ID`) with variables:
* - `target_email_address`
* - `registration_link`
*/
export async function sendRegistrationEmail(options: { email: string; url: string }) {
await sendMail({
to: options.email,
subject: 'Welcome to Radiant Wave — set your password',
templateId: env.RESEND_REGISTRATION_TEMPLATE_ID || 'registration-email',
data: {
target_email_address: options.email,
registration_link: options.url
}
});
}
+18
View File
@@ -0,0 +1,18 @@
import { auth } from '$lib/auth';
/**
* Email an invited user a link to set their password and finish their profile.
*
* Reuses Better Auth's password-reset token flow; the `invite=1` marker makes
* the reset-password hook send the `registration-email` template instead of the
* usual "you requested a reset" email. The link lands on /reset-password, and
* after setting a password the user logs in and is sent to /profile/setup.
*/
export async function sendRegistrationInvite(email: string): Promise<void> {
await auth.api.requestPasswordReset({
body: {
email,
redirectTo: '/reset-password?invite=1'
}
});
}
+3 -3
View File
@@ -26,7 +26,7 @@ function getResend(): Resend {
* @example
* await sendMail({
* to: 'jane@example.com',
* templateId: 'tmpl_abc123', // optional — defaults to RESEND_TEMPLATE_ID
* templateId: 'tmpl_abc123', // required — Resend template id or alias
* subject: 'Your license key is ready', // optional — defaults to 'Template Email'
* data: { // optional — template variables
* firstName: 'Jane',
@@ -40,7 +40,7 @@ function getResend(): Resend {
export async function sendMail(options: {
to: string;
subject?: string;
templateId?: string;
templateId: string;
data?: Record<string, string | number>;
}) {
const { to, subject, templateId, data } = options;
@@ -50,7 +50,7 @@ export async function sendMail(options: {
to: [to],
subject: subject ?? 'Template Email',
template: {
id: templateId ?? env.RESEND_TEMPLATE_ID,
id: templateId,
variables: data ?? {}
}
});
+28
View File
@@ -0,0 +1,28 @@
/**
* Role hierarchy. Lower ids are more privileged:
* SUPER = 1, ADMIN = 2, STAFF = 3, USER = 4
*
* `roleId` is exposed on the Better Auth session user (see `auth.ts`
* `user.additionalFields`), so these helpers never need a database call.
*/
export const SUPER = 1;
export const ADMIN = 2;
export const STAFF = 3;
export const USER = 4;
/** The current user's role id, defaulting to the least privileged role. */
export function roleId(locals: App.Locals): number {
return locals.user?.roleId ?? USER;
}
export function isSuper(locals: App.Locals): boolean {
return roleId(locals) <= SUPER;
}
export function isAdmin(locals: App.Locals): boolean {
return roleId(locals) <= ADMIN;
}
export function isStaff(locals: App.Locals): boolean {
return roleId(locals) <= STAFF;
}
+200
View File
@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn() },
block: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule';
const scheduleFindUnique = prisma.schedule.findUnique as ReturnType<typeof vi.fn>;
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
const blockFindFirst = prisma.block.findFirst as ReturnType<typeof vi.fn>;
const blockCreate = prisma.block.create as ReturnType<typeof vi.fn>;
const blockUpdate = prisma.block.update as ReturnType<typeof vi.fn>;
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
const validInput = {
startMinutes: 6 * 60,
endMinutes: 8 * 60,
proclamationIds: ['p1']
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOrCreateSchedule', () => {
it('returns the existing schedule', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
const result = await getOrCreateSchedule('u1');
expect(result).toEqual({ id: 's1', userId: 'u1' });
expect(scheduleCreate).not.toHaveBeenCalled();
});
it('creates the singleton schedule when missing', async () => {
scheduleFindUnique.mockResolvedValue(null);
scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' });
await getOrCreateSchedule('u1');
expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } });
});
});
describe('listBlocks', () => {
it('scopes to the user and includes proclamations with frequencies', async () => {
blockFindMany.mockResolvedValue([]);
await listBlocks('u1');
expect(blockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { schedule: { userId: 'u1' } },
orderBy: { startMinutes: 'asc' },
include: expect.objectContaining({ proclamations: expect.anything() })
})
);
});
});
describe('createBlock', () => {
beforeEach(() => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
});
it('creates a block with deduped proclamations in order', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
const result = await createBlock('u1', {
...validInput,
proclamationIds: ['p1', 'p1', 'p2']
});
expect(result).toEqual({ ok: true, id: 'new-block' });
expect(blockCreate).toHaveBeenCalledWith({
data: {
scheduleId: 's1',
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: false,
proclamations: {
create: [
{ proclamationId: 'p1', sortOrder: 0 },
{ proclamationId: 'p2', sortOrder: 1 }
]
}
}
});
});
it('persists the dynamic audio flag', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
await createBlock('u1', { ...validInput, dynamicAudio: true });
expect(blockCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ dynamicAudio: true })
})
);
});
it('rejects a fifth block without creating', async () => {
blockFindMany.mockResolvedValue([
{ id: 'a', startMinutes: 0, endMinutes: 60 },
{ id: 'b', startMinutes: 60, endMinutes: 120 },
{ id: 'c', startMinutes: 120, endMinutes: 180 },
{ id: 'd', startMinutes: 180, endMinutes: 240 }
]);
const result = await createBlock('u1', validInput);
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
expect(blockCreate).not.toHaveBeenCalled();
});
it('rejects an overlapping block with a specific message', async () => {
blockFindMany.mockResolvedValue([{ id: 'a', startMinutes: 7 * 60, endMinutes: 9 * 60 }]);
const result = await createBlock('u1', validInput);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 7:00 AM–9:00 AM block.'
});
expect(blockCreate).not.toHaveBeenCalled();
});
});
describe('updateBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await updateBlock('u1', 'missing', validInput);
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockUpdate).not.toHaveBeenCalled();
});
it('replaces proclamations and excludes itself from overlap checks', async () => {
blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' });
blockFindMany.mockResolvedValue([]);
blockUpdate.mockResolvedValue({ id: 'b1' });
const result = await updateBlock('u1', 'b1', validInput);
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockFindMany).toHaveBeenCalledWith({
where: { scheduleId: 's1', id: { not: 'b1' } },
select: { id: true, startMinutes: true, endMinutes: true }
});
expect(blockUpdate).toHaveBeenCalledWith({
where: { id: 'b1' },
data: {
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: false,
proclamations: {
deleteMany: {},
create: [{ proclamationId: 'p1', sortOrder: 0 }]
}
}
});
});
});
describe('deleteBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await deleteBlock('u1', 'missing');
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockDelete).not.toHaveBeenCalled();
});
it('deletes an owned block', async () => {
blockFindFirst.mockResolvedValue({ id: 'b1' });
blockDelete.mockResolvedValue({ id: 'b1' });
const result = await deleteBlock('u1', 'b1');
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
});
});
+131
View File
@@ -0,0 +1,131 @@
// Schedule service layer. Owns the database orchestration and enforcement of
// the Block business rules (at most 4 per user, at least one Proclamation,
// no overnight ranges, no overlaps). Pure validation lives in
// `$lib/schedule-utils` so the client modal can reuse it.
import { prisma } from '$lib/db';
import { validateBlock, type BlockInput } from '$lib/schedule-utils';
export {
MAX_BLOCKS,
MINUTES_PER_DAY,
SNAP_MINUTES,
MINUTE_STEP,
formatTime,
toHm,
snapMinutes,
findOverlap,
validateBlock
} from '$lib/schedule-utils';
export type { BlockInput, BlockSibling, ValidationResult } from '$lib/schedule-utils';
export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string };
function uniqueIds(ids: string[]): string[] {
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))];
}
/** Ensure the user has a Schedule row, creating the singleton on first use. */
export async function getOrCreateSchedule(userId: string) {
const existing = await prisma.schedule.findUnique({ where: { userId } });
if (existing) return existing;
return prisma.schedule.create({ data: { userId } });
}
/**
* The user's Blocks with their selected Proclamations (and frequencies for
* canonical-id mapping), ordered by start time.
*/
export async function listBlocks(userId: string) {
return prisma.block.findMany({
where: { schedule: { userId } },
orderBy: { startMinutes: 'asc' },
include: {
proclamations: {
orderBy: { sortOrder: 'asc' },
include: {
proclamation: { include: { solfeggioFrequency: true } }
}
}
}
});
}
export async function createBlock(userId: string, input: BlockInput): Promise<BlockMutationResult> {
const schedule = await getOrCreateSchedule(userId);
const siblings = await prisma.block.findMany({
where: { scheduleId: schedule.id },
select: { id: true, startMinutes: true, endMinutes: true }
});
const validation = validateBlock(input, siblings, { isCreate: true });
if (!validation.ok) return validation;
const proclamationIds = uniqueIds(input.proclamationIds);
const block = await prisma.block.create({
data: {
scheduleId: schedule.id,
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
dynamicAudio: input.dynamicAudio === true,
proclamations: {
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
return { ok: true, id: block.id };
}
export async function updateBlock(
userId: string,
blockId: string,
input: BlockInput
): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { userId } },
select: { id: true, scheduleId: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
const siblings = await prisma.block.findMany({
where: { scheduleId: block.scheduleId, id: { not: blockId } },
select: { id: true, startMinutes: true, endMinutes: true }
});
const validation = validateBlock(input, siblings);
if (!validation.ok) return validation;
const proclamationIds = uniqueIds(input.proclamationIds);
await prisma.block.update({
where: { id: blockId },
data: {
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
dynamicAudio: input.dynamicAudio === true,
proclamations: {
deleteMany: {},
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
return { ok: true, id: blockId };
}
export async function deleteBlock(userId: string, blockId: string): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { userId } },
select: { id: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
await prisma.block.delete({ where: { id: blockId } });
return { ok: true, id: blockId };
}
+45
View File
@@ -1,3 +1,5 @@
import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils';
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
// canonical ids (1-9), never the portal DB autoincrement ids, because
@@ -138,3 +140,46 @@ export function serializeColorPalette(palette: {
}): V2ColorPalette {
return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors };
}
export type V2ScheduleBlock = {
id: string;
startTime: string;
startMinutes: number;
endTime: string;
endMinutes: number;
overnight: boolean;
durationMinutes: number;
dynamicAudio: boolean;
proclamationIds: string[];
};
export type V2Schedule = {
email: string;
blocks: V2ScheduleBlock[];
};
/** Build a schedule Block for the kiosk. Only the selected Proclamation IDs are
* returned; the kiosk fetches proclamation content separately. `overnight`
* marks a range that crosses midnight (end clock time before start), and
* `durationMinutes` counts the wrap as the next day. */
export function serializeScheduleBlock(
block: {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio?: boolean;
},
proclamationIds: string[]
): V2ScheduleBlock {
return {
id: block.id,
startTime: toHm(block.startMinutes),
startMinutes: block.startMinutes,
endTime: toHm(block.endMinutes),
endMinutes: block.endMinutes,
overnight: isOvernight(block),
durationMinutes: blockDuration(block),
dynamicAudio: block.dynamicAudio === true,
proclamationIds
};
}
+2
View File
@@ -88,6 +88,8 @@ export function mockLicenseKey(overrides?: Record<string, unknown>) {
id: 'key-1',
key: 'AAAA-BBBB-CCCC-DDDD',
active: true,
createdAt: new Date('2026-01-01T00:00:00.000Z'),
lastValidatedAt: null,
level: 'standard',
userId: 'user-1',
...overrides
@@ -1,208 +0,0 @@
<script lang="ts">
import { clientFormatPhoneNumber } from '$lib/utils';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let initialPhone = $derived(clientFormatPhoneNumber(data.user.profile?.phoneNumber ?? ''));
let phoneNumber = $state('');
let phoneInitialized = $state(false);
$effect(() => {
if (!phoneInitialized) {
phoneNumber = initialPhone;
phoneInitialized = true;
}
});
let showAddressForm = $state(false);
function handlePhoneInput(event: Event) {
const input = event.target as HTMLInputElement;
phoneNumber = clientFormatPhoneNumber(input.value);
}
</script>
<div class="mx-auto w-full max-w-3xl">
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Account</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base">Manage your profile and addresses.</p>
<!-- Email (read-only) -->
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<p class="text-xs tracking-wide text-slate-500 uppercase">Email</p>
<p class="mt-1 text-base break-words text-slate-900">{data.user.email}</p>
</div>
<!-- Profile Form -->
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<h2 class="text-lg font-semibold text-slate-900">Profile</h2>
{#if form?.action === 'updateProfile' && form?.success}
<div class="mt-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800" role="status">
Profile updated successfully.
</div>
{/if}
{#if form?.action === 'updateProfile' && form?.error}
<div class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{form.error}
</div>
{/if}
<form method="POST" action="?/updateProfile" class="mt-5 flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="firstName" class="text-sm font-medium text-slate-700"> First Name </label>
<input
type="text"
id="firstName"
name="firstName"
value={data.user.profile?.firstName ?? ''}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="lastName" class="text-sm font-medium text-slate-700"> Last Name </label>
<input
type="text"
id="lastName"
name="lastName"
value={data.user.profile?.lastName ?? ''}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="phoneNumber" class="text-sm font-medium text-slate-700"> Phone Number </label>
<input
type="tel"
id="phoneNumber"
name="phoneNumber"
bind:value={phoneNumber}
oninput={handlePhoneInput}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div>
<button
type="submit"
class="flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 sm:w-auto sm:px-8"
>
Save Profile
</button>
</div>
</form>
</section>
<!-- Addresses -->
<section class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
<div class="flex items-center justify-between gap-4">
<h2 class="text-lg font-semibold text-slate-900">Addresses</h2>
<button
type="button"
onclick={() => (showAddressForm = !showAddressForm)}
class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
{showAddressForm ? 'Cancel' : 'Add Address'}
</button>
</div>
{#if form?.action === 'addAddress' && form?.success}
<div class="mt-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800" role="status">
Address added successfully.
</div>
{/if}
{#if form?.action === 'addAddress' && form?.error}
<div class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{form.error}
</div>
{/if}
{#if showAddressForm}
<form method="POST" action="?/addAddress" class="mt-5 flex flex-col gap-4">
<div class="flex flex-col gap-1.5">
<label for="street" class="text-sm font-medium text-slate-700">Street</label>
<input
type="text"
id="street"
name="street"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="city" class="text-sm font-medium text-slate-700">City</label>
<input
type="text"
id="city"
name="city"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="state" class="text-sm font-medium text-slate-700">State</label>
<input
type="text"
id="state"
name="state"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="postalCode" class="text-sm font-medium text-slate-700"> Postal Code </label>
<input
type="text"
id="postalCode"
name="postalCode"
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="country" class="text-sm font-medium text-slate-700">Country</label>
<input
type="text"
id="country"
name="country"
required
value="US"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
</div>
<div>
<button
type="submit"
class="flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 sm:w-auto sm:px-8"
>
Save Address
</button>
</div>
</form>
{/if}
{#if data.user.profile?.addresses && data.user.profile.addresses.length > 0}
<div class="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each data.user.profile.addresses as address (address.id)}
<div class="rounded-lg border border-slate-200 bg-slate-50 p-4">
<p class="text-sm font-medium text-slate-900">{address.street}</p>
<p class="mt-1 text-sm text-slate-600">
{address.city}, {address.state}
{address.postalCode}
</p>
<p class="text-sm text-slate-600">{address.country}</p>
</div>
{/each}
</div>
{:else if !showAddressForm}
<p class="mt-5 text-sm text-slate-500">No addresses on file.</p>
{/if}
</section>
</div>
@@ -7,23 +7,10 @@ export const load: PageServerLoad = async ({ locals }) => {
throw error(403, 'not authorized');
}
const profile = await prisma.profile.findFirst({
const licenseKeys = await prisma.licenseKey.findMany({
where: { userId: locals.user.id },
select: { firstName: true }
orderBy: { createdAt: 'desc' }
});
const keyStats = await prisma.licenseKey.groupBy({
by: ['active'],
where: { userId: locals.user.id },
_count: true
});
const activeKeys = keyStats.find((s) => s.active)?._count ?? 0;
const totalKeys = keyStats.reduce((sum, s) => sum + s._count, 0);
return {
firstName: profile?.firstName ?? 'User',
activeKeys,
totalKeys
};
return { licenseKeys };
};
@@ -1,33 +1,127 @@
<script lang="ts">
import { resolve } from '$app/paths';
import DashboardSection from '$lib/components/DashboardSection.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function formatDate(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
</script>
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Welcome, {data.firstName}!
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base">Here's an overview of your account.</p>
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<a
href={resolve('/license-keys')}
class="group flex flex-col gap-1 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 hover:shadow"
>
<span class="text-sm font-medium text-slate-600">Active License Keys</span>
<span class="text-3xl font-bold text-indigo-600">{data.activeKeys}</span>
<span class="text-sm text-slate-500">{data.totalKeys} total</span>
</a>
<a
href={resolve('/account')}
class="group flex flex-col gap-1 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 hover:shadow"
>
<span class="text-sm font-medium text-slate-600">Account</span>
<span class="text-2xl font-semibold text-slate-900">Manage Profile</span>
<span class="text-sm text-slate-500">Edit your details</span>
</a>
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-100">
Dashboard
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
Manage your account and license keys.
</p>
</div>
<!--
Dashboard sections. This page is the account management surface; personal
information (name, email, phone, addresses) lives on the Profile page.
Planned sections to add below as <DashboardSection> blocks:
- Schedules: create and manage listening schedules.
- Devices / playback.
- Billing / orders.
-->
<DashboardSection title="License Keys" description="Your active and inactive license keys.">
{#if data.licenseKeys.length > 0}
<div
class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<!-- Desktop / tablet: table -->
<table class="hidden w-full text-left text-sm sm:table">
<thead
class="border-b border-slate-200 bg-slate-50 text-xs tracking-wide text-slate-500 uppercase dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400"
>
<tr>
<th class="px-6 py-3 font-semibold">Key</th>
<th class="px-6 py-3 font-semibold">Status</th>
<th class="px-6 py-3 font-semibold">Created</th>
<th class="px-6 py-3 font-semibold">Last verified</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{#each data.licenseKeys as key (key.key)}
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800">
<td class="px-6 py-4 font-mono text-slate-800 dark:text-slate-200">{key.key}</td>
<td class="px-6 py-4">
{#if key.active}
<span
class="text-lg text-green-600 dark:text-green-400"
title="Active"
aria-label="Active">☑</span
>
{:else}
<span
class="text-lg text-slate-300 dark:text-slate-600"
title="Inactive"
aria-label="Inactive">☐</span
>
{/if}
</td>
<td class="px-6 py-4 text-slate-600 dark:text-slate-400"
>{formatDate(key.createdAt)}</td
>
<td class="px-6 py-4 text-slate-600 dark:text-slate-400">
{key.lastValidatedAt ? formatDate(key.lastValidatedAt) : 'Never'}
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Mobile: card list -->
<ul class="divide-y divide-slate-100 sm:hidden">
{#each data.licenseKeys as key (key.key)}
<li class="flex flex-col gap-2 px-4 py-4">
<div class="flex items-center justify-between gap-4">
<span class="min-w-0 font-mono text-sm break-all text-slate-800 dark:text-slate-200"
>{key.key}</span
>
<span class="shrink-0">
{#if key.active}
<span
class="text-lg text-green-600 dark:text-green-400"
title="Active"
aria-label="Active">☑</span
>
{:else}
<span
class="text-lg text-slate-300 dark:text-slate-600"
title="Inactive"
aria-label="Inactive">☐</span
>
{/if}
</span>
</div>
<span class="text-xs text-slate-500 dark:text-slate-400"
>Created {formatDate(key.createdAt)}</span
>
<span class="text-xs text-slate-500 dark:text-slate-400">
Last verified {key.lastValidatedAt ? formatDate(key.lastValidatedAt) : 'Never'}
</span>
</li>
{/each}
</ul>
</div>
{:else}
<div
class="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-900"
>
<p class="text-sm text-slate-600 dark:text-slate-400">
You don't have any license keys yet.
</p>
</div>
{/if}
</DashboardSection>
</div>
@@ -1,24 +1,21 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser } from '$lib/test-utils';
import { createLocals, mockUser, mockLicenseKey } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
profile: { findFirst: vi.fn() },
licenseKey: { groupBy: vi.fn() }
licenseKey: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType<typeof vi.fn>;
const licenseKeyFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
describe('(protected)/dashboard page server load', () => {
beforeEach(() => {
profileFindFirst.mockReset();
licenseKeyGroupBy.mockReset();
licenseKeyFindMany.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
@@ -28,44 +25,35 @@ describe('(protected)/dashboard page server load', () => {
expect(isHttpError(err, 403)).toBe(true);
});
it('returns firstName from profile', async () => {
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([]);
it('returns license keys for the authenticated user', async () => {
const keys = [
mockLicenseKey(),
mockLicenseKey({ id: 'key-2', key: 'EEEE-FFFF-GGGG-HHHH', active: false })
];
licenseKeyFindMany.mockResolvedValue(keys);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.firstName).toBe('Jane');
expect(result.licenseKeys).toEqual(keys);
});
it('defaults firstName to "User" when profile is null', async () => {
profileFindFirst.mockResolvedValue(null);
licenseKeyGroupBy.mockResolvedValue([]);
it('returns empty array when user has no keys', async () => {
licenseKeyFindMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.firstName).toBe('User');
expect(result.licenseKeys).toEqual([]);
});
it('computes activeKeys and totalKeys from groupBy results', async () => {
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([
{ active: true, _count: 3 },
{ active: false, _count: 2 }
]);
it('loads license keys filtered by user, newest first', async () => {
licenseKeyFindMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.activeKeys).toBe(3);
expect(result.totalKeys).toBe(5);
});
await load({ locals } as any);
it('returns 0 for activeKeys when none are active', async () => {
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([{ active: false, _count: 4 }]);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.activeKeys).toBe(0);
expect(result.totalKeys).toBe(4);
expect(licenseKeyFindMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
orderBy: { createdAt: 'desc' }
});
});
});
@@ -1,16 +0,0 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const licenseKeys = await prisma.licenseKey.findMany({
where: { userId: locals.user.id },
orderBy: { key: 'asc' }
});
return { licenseKeys };
};
@@ -1,66 +0,0 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">License Keys</h1>
<p class="mt-1 text-sm text-slate-500">Your active and inactive license keys.</p>
</div>
</div>
{#if data.licenseKeys.length > 0}
<div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<!-- Desktop / tablet: table -->
<table class="hidden w-full text-left text-sm sm:table">
<thead
class="border-b border-slate-200 bg-slate-50 text-xs tracking-wide text-slate-500 uppercase"
>
<tr>
<th class="px-6 py-3 font-semibold">Key</th>
<th class="px-6 py-3 font-semibold">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{#each data.licenseKeys as key (key.key)}
<tr class="hover:bg-slate-50">
<td class="px-6 py-4 font-mono text-slate-800">{key.key}</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {key.active
? 'bg-green-100 text-green-700'
: 'bg-slate-100 text-slate-600'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Mobile: card list -->
<ul class="divide-y divide-slate-100 sm:hidden">
{#each data.licenseKeys as key (key.key)}
<li class="flex items-center justify-between gap-4 px-4 py-4">
<span class="min-w-0 font-mono text-sm break-all text-slate-800">{key.key}</span>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {key.active
? 'bg-green-100 text-green-700'
: 'bg-slate-100 text-slate-600'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
</li>
{/each}
</ul>
</div>
{:else}
<div class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center">
<p class="text-sm text-slate-600">You don't have any license keys yet.</p>
</div>
{/if}
</div>
@@ -1,59 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser, mockLicenseKey } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const findMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
describe('(protected)/license-keys page server load', () => {
beforeEach(() => {
findMany.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns license keys for the authenticated user', async () => {
const keys = [
mockLicenseKey(),
mockLicenseKey({ id: 'key-2', key: 'EEEE-FFFF-GGGG-HHHH', active: false })
];
findMany.mockResolvedValue(keys);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.licenseKeys).toEqual(keys);
});
it('passes userId in the where clause', async () => {
findMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
await load({ locals } as any);
expect(findMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
orderBy: { key: 'asc' }
});
});
it('returns empty array when user has no keys', async () => {
findMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.licenseKeys).toEqual([]);
});
});
@@ -90,5 +90,77 @@ export const actions: Actions = {
console.error('Address create error:', err);
return fail(500, { error: 'Failed to add address.', action: 'addAddress' });
}
},
updateAddress: async ({ request, locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const data = await request.formData();
const id = data.get('id') as string;
const street = data.get('street') as string;
const city = data.get('city') as string;
const state = data.get('state') as string;
const postalCode = data.get('postalCode') as string;
const country = data.get('country') as string;
if (!id) {
return fail(400, { error: 'Missing address id.', action: 'updateAddress' });
}
if (!street || !city || !state || !postalCode || !country) {
return fail(400, { error: 'All address fields are required.', action: 'updateAddress' });
}
const address = await prisma.address.findFirst({
where: { id, profile: { userId: locals.user.id } },
select: { id: true }
});
if (!address) {
throw error(404, 'Address not found');
}
try {
await prisma.address.update({
where: { id },
data: { street, city, state, postalCode, country }
});
return { success: true, action: 'updateAddress' };
} catch (err) {
console.error('Address update error:', err);
return fail(500, { error: 'Failed to update address.', action: 'updateAddress' });
}
},
deleteAddress: async ({ request, locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const data = await request.formData();
const id = data.get('id') as string;
if (!id) {
return fail(400, { error: 'Missing address id.', action: 'deleteAddress' });
}
const address = await prisma.address.findFirst({
where: { id, profile: { userId: locals.user.id } },
select: { id: true }
});
if (!address) {
throw error(404, 'Address not found');
}
try {
await prisma.address.delete({ where: { id } });
return { success: true, action: 'deleteAddress' };
} catch (err) {
console.error('Address delete error:', err);
return fail(500, { error: 'Failed to delete address.', action: 'deleteAddress' });
}
}
};
@@ -0,0 +1,337 @@
<script lang="ts">
import { clientFormatPhoneNumber } from '$lib/utils';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let initialPhone = $derived(clientFormatPhoneNumber(data.user.profile?.phoneNumber ?? ''));
let phoneNumber = $state('');
let phoneInitialized = $state(false);
$effect(() => {
if (!phoneInitialized) {
phoneNumber = initialPhone;
phoneInitialized = true;
}
});
let showAddressForm = $state(false);
let editingAddressId = $state<string | null>(null);
let addrStreet = $state('');
let addrCity = $state('');
let addrState = $state('');
let addrPostal = $state('');
let addrCountry = $state('US');
function openAddAddress() {
editingAddressId = null;
addrStreet = '';
addrCity = '';
addrState = '';
addrPostal = '';
addrCountry = 'US';
showAddressForm = true;
}
function openEditAddress(address: {
id: string;
street: string;
city: string;
state: string;
postalCode: string;
country: string;
}) {
editingAddressId = address.id;
addrStreet = address.street;
addrCity = address.city;
addrState = address.state;
addrPostal = address.postalCode;
addrCountry = address.country;
showAddressForm = true;
}
function closeAddressForm() {
showAddressForm = false;
editingAddressId = null;
}
function handlePhoneInput(event: Event) {
const input = event.target as HTMLInputElement;
phoneNumber = clientFormatPhoneNumber(input.value);
}
</script>
<div class="mx-auto w-full max-w-3xl">
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-100">
Profile
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
Manage your account details and addresses.
</p>
<!-- Email (read-only) -->
<div
class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900"
>
<p class="text-xs tracking-wide text-slate-500 uppercase dark:text-slate-400">Email</p>
<p class="mt-1 text-base break-words text-slate-900 dark:text-slate-100">{data.user.email}</p>
</div>
<!-- Profile Form -->
<section
class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900"
>
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">Profile</h2>
{#if form?.action === 'updateProfile' && form?.success}
<div
class="mt-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800 dark:bg-green-950/50 dark:text-green-300"
role="status"
>
Profile updated successfully.
</div>
{/if}
{#if form?.action === 'updateProfile' && form?.error}
<div
class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/50 dark:text-red-300"
role="alert"
>
{form.error}
</div>
{/if}
<form method="POST" action="?/updateProfile" class="mt-5 flex flex-col gap-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="firstName" class="text-sm font-medium text-slate-700 dark:text-slate-300">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={data.user.profile?.firstName ?? ''}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="lastName" class="text-sm font-medium text-slate-700 dark:text-slate-300">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={data.user.profile?.lastName ?? ''}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="phoneNumber" class="text-sm font-medium text-slate-700 dark:text-slate-300">
Phone Number
</label>
<input
type="tel"
id="phoneNumber"
name="phoneNumber"
bind:value={phoneNumber}
oninput={handlePhoneInput}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div>
<button
type="submit"
class="flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 sm:w-auto sm:px-8"
>
Save Profile
</button>
</div>
</form>
</section>
<!-- Addresses -->
<section
class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6 dark:border-slate-800 dark:bg-slate-900"
>
<div class="flex items-center justify-between gap-4">
<h2 class="text-lg font-semibold text-slate-900 dark:text-slate-100">Addresses</h2>
<button
type="button"
onclick={() => (showAddressForm ? closeAddressForm() : openAddAddress())}
class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
{showAddressForm ? 'Cancel' : 'Add Address'}
</button>
</div>
{#if form?.action === 'addAddress' && form?.success}
<div
class="mt-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800 dark:bg-green-950/50 dark:text-green-300"
role="status"
>
Address added successfully.
</div>
{/if}
{#if form?.action === 'updateAddress' && form?.success}
<div
class="mt-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800 dark:bg-green-950/50 dark:text-green-300"
role="status"
>
Address updated successfully.
</div>
{/if}
{#if form?.action === 'deleteAddress' && form?.success}
<div
class="mt-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800 dark:bg-green-950/50 dark:text-green-300"
role="status"
>
Address deleted successfully.
</div>
{/if}
{#if (form?.action === 'addAddress' || form?.action === 'updateAddress' || form?.action === 'deleteAddress') && form?.error}
<div
class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-950/50 dark:text-red-300"
role="alert"
>
{form.error}
</div>
{/if}
{#if showAddressForm}
<form
method="POST"
action={editingAddressId ? '?/updateAddress' : '?/addAddress'}
class="mt-5 flex flex-col gap-4"
>
{#if editingAddressId}
<input type="hidden" name="id" value={editingAddressId} />
{/if}
<div class="flex flex-col gap-1.5">
<label for="street" class="text-sm font-medium text-slate-700 dark:text-slate-300"
>Street</label
>
<input
type="text"
id="street"
name="street"
bind:value={addrStreet}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="city" class="text-sm font-medium text-slate-700 dark:text-slate-300"
>City</label
>
<input
type="text"
id="city"
name="city"
bind:value={addrCity}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="state" class="text-sm font-medium text-slate-700 dark:text-slate-300"
>State</label
>
<input
type="text"
id="state"
name="state"
bind:value={addrState}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="postalCode" class="text-sm font-medium text-slate-700 dark:text-slate-300">
Postal Code
</label>
<input
type="text"
id="postalCode"
name="postalCode"
bind:value={addrPostal}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="country" class="text-sm font-medium text-slate-700 dark:text-slate-300"
>Country</label
>
<input
type="text"
id="country"
name="country"
bind:value={addrCountry}
required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
</div>
<div>
<button
type="submit"
class="flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 sm:w-auto sm:px-8"
>
{editingAddressId ? 'Update Address' : 'Save Address'}
</button>
</div>
</form>
{/if}
{#if data.user.profile?.addresses && data.user.profile.addresses.length > 0}
<div class="mt-5 grid grid-cols-1 gap-4 sm:grid-cols-2">
{#each data.user.profile.addresses as address (address.id)}
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<p class="text-sm font-medium text-slate-900 dark:text-slate-100">{address.street}</p>
<p class="mt-1 text-sm text-slate-600 dark:text-slate-400">
{address.city}, {address.state}
{address.postalCode}
</p>
<p class="text-sm text-slate-600 dark:text-slate-400">{address.country}</p>
<div class="mt-3 flex gap-2">
<button
type="button"
onclick={() => openEditAddress(address)}
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Edit
</button>
<form
method="POST"
action="?/deleteAddress"
onsubmit={(event) => {
if (!confirm('Delete this address?')) event.preventDefault();
}}
>
<input type="hidden" name="id" value={address.id} />
<button
type="submit"
class="rounded-md border border-red-300 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 dark:border-red-800 dark:bg-slate-900 dark:text-red-400 dark:hover:bg-red-950/50"
>
Delete
</button>
</form>
</div>
</div>
{/each}
</div>
{:else if !showAddressForm}
<p class="mt-5 text-sm text-slate-500 dark:text-slate-400">No addresses on file.</p>
{/if}
</section>
</div>
@@ -11,7 +11,7 @@ vi.mock('$lib/db', () => ({
prisma: {
user: { findUnique: vi.fn() },
profile: { findFirst: vi.fn(), updateMany: vi.fn() },
address: { create: vi.fn() }
address: { create: vi.fn(), findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() }
}
}));
@@ -22,16 +22,22 @@ const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
const profileUpdateMany = prisma.profile.updateMany as ReturnType<typeof vi.fn>;
const addressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
const addressFindFirst = prisma.address.findFirst as ReturnType<typeof vi.fn>;
const addressUpdate = prisma.address.update as ReturnType<typeof vi.fn>;
const addressDelete = prisma.address.delete as ReturnType<typeof vi.fn>;
const invokeAction = (name: string, request: Request, locals: App.Locals) =>
(actions as any)[name]({ request, locals } as any);
describe('(protected)/account page server', () => {
describe('(protected)/profile page server', () => {
beforeEach(() => {
userFindUnique.mockReset();
profileFindFirst.mockReset();
profileUpdateMany.mockReset();
addressCreate.mockReset();
addressFindFirst.mockReset();
addressUpdate.mockReset();
addressDelete.mockReset();
});
describe('load', () => {
@@ -254,4 +260,164 @@ describe('(protected)/account page server', () => {
expect((result as any).status).toBe(500);
});
});
describe('updateAddress action', () => {
it('throws 403 when locals.user is undefined', async () => {
const request = createFormRequest({
id: 'addr-1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const err = await Promise.resolve(
invokeAction('updateAddress', request, createLocals())
).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns fail(400) when id is missing', async () => {
const request = createFormRequest({
id: '',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).data.action).toBe('updateAddress');
});
it('throws 404 when the address is not owned by the user', async () => {
addressFindFirst.mockResolvedValue(null);
const request = createFormRequest({
id: 'addr-1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(invokeAction('updateAddress', request, locals)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 404)).toBe(true);
expect(addressFindFirst).toHaveBeenCalledWith({
where: { id: 'addr-1', profile: { userId: 'user-1' } },
select: { id: true }
});
});
it('updates the owned address on success', async () => {
addressFindFirst.mockResolvedValue({ id: 'addr-1' });
addressUpdate.mockResolvedValue({});
const request = createFormRequest({
id: 'addr-1',
street: '456 Oak Ave',
city: 'Austin',
state: 'TX',
postalCode: '73301',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateAddress', request, locals);
expect(result).toEqual({ success: true, action: 'updateAddress' });
expect(addressUpdate).toHaveBeenCalledWith({
where: { id: 'addr-1' },
data: {
street: '456 Oak Ave',
city: 'Austin',
state: 'TX',
postalCode: '73301',
country: 'US'
}
});
});
it('returns fail(500) when prisma update throws', async () => {
addressFindFirst.mockResolvedValue({ id: 'addr-1' });
addressUpdate.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
id: 'addr-1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).status).toBe(500);
});
});
describe('deleteAddress action', () => {
it('throws 403 when locals.user is undefined', async () => {
const request = createFormRequest({ id: 'addr-1' });
const err = await Promise.resolve(
invokeAction('deleteAddress', request, createLocals())
).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns fail(400) when id is missing', async () => {
const request = createFormRequest({ id: '' });
const locals = createLocals(mockUser() as any);
const result = await invokeAction('deleteAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).data.action).toBe('deleteAddress');
});
it('throws 404 when the address is not owned by the user', async () => {
addressFindFirst.mockResolvedValue(null);
const request = createFormRequest({ id: 'addr-1' });
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(invokeAction('deleteAddress', request, locals)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 404)).toBe(true);
});
it('deletes the owned address on success', async () => {
addressFindFirst.mockResolvedValue({ id: 'addr-1' });
addressDelete.mockResolvedValue({});
const request = createFormRequest({ id: 'addr-1' });
const locals = createLocals(mockUser() as any);
const result = await invokeAction('deleteAddress', request, locals);
expect(result).toEqual({ success: true, action: 'deleteAddress' });
expect(addressDelete).toHaveBeenCalledWith({ where: { id: 'addr-1' } });
});
it('returns fail(500) when prisma delete throws', async () => {
addressFindFirst.mockResolvedValue({ id: 'addr-1' });
addressDelete.mockRejectedValue(new Error('db error'));
const request = createFormRequest({ id: 'addr-1' });
const locals = createLocals(mockUser() as any);
const result = await invokeAction('deleteAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).status).toBe(500);
});
});
});
@@ -43,7 +43,7 @@
<Window>
<form method="POST" class="flex flex-col items-center gap-5">
<h1 class="text-center text-2xl font-bold text-slate-900">
<h1 class="text-center text-2xl font-bold text-slate-900 dark:text-slate-100">
Please finish setting up your profile
</h1>
@@ -55,44 +55,50 @@
/>
{:else}
<div
class="flex h-24 w-24 items-center justify-center rounded-full bg-indigo-100 text-3xl font-semibold text-indigo-700 sm:h-28 sm:w-28"
class="flex h-24 w-24 items-center justify-center rounded-full bg-indigo-100 text-3xl font-semibold text-indigo-700 sm:h-28 sm:w-28 dark:bg-indigo-900 dark:text-indigo-300"
>
{data.user?.name?.charAt(0)?.toUpperCase() ?? '?'}
</div>
{/if}
<p class="text-base text-slate-900">{data.user?.email}</p>
<p class="text-base text-slate-900 dark:text-slate-100">{data.user?.email}</p>
<div class="flex w-full flex-col gap-4">
<input type="hidden" name="userId" value={data.user?.id} />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="firstName" class="text-sm font-medium text-slate-700"> First Name </label>
<label for="firstName" class="text-sm font-medium text-slate-700 dark:text-slate-300">
First Name
</label>
<input
id="firstName"
type="text"
name="firstName"
placeholder="First Name"
bind:value={firstName}
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="lastName" class="text-sm font-medium text-slate-700"> Last Name </label>
<label for="lastName" class="text-sm font-medium text-slate-700 dark:text-slate-300">
Last Name
</label>
<input
id="lastName"
type="text"
name="lastName"
placeholder="Last Name"
bind:value={lastName}
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
</div>
<div class="flex flex-col gap-1.5">
<label for="phoneNumber" class="text-sm font-medium text-slate-700"> Phone Number </label>
<label for="phoneNumber" class="text-sm font-medium text-slate-700 dark:text-slate-300">
Phone Number
</label>
<input
id="phoneNumber"
type="tel"
@@ -100,7 +106,7 @@
placeholder="(555) 555-5555"
bind:value={phoneNumber}
oninput={handlePhoneInput}
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
</div>
@@ -27,7 +27,7 @@ import { load, actions } from './+page.server';
const invokeDefault = (request: Request) => actions.default({ request } as any);
describe('account setup page server load', () => {
describe('profile setup page server load', () => {
beforeEach(() => {
create.mockReset();
});
@@ -48,7 +48,7 @@ describe('account setup page server load', () => {
});
});
describe('account setup page server action', () => {
describe('profile setup page server action', () => {
beforeEach(() => {
create.mockReset();
});
@@ -0,0 +1,91 @@
import type { Actions, PageServerLoad } from './$types';
import { error, fail } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks, updateBlock } from '$lib/server/schedule';
function parseInput(formData: FormData) {
const startMinutes = Number(formData.get('startMinutes'));
const endMinutes = Number(formData.get('endMinutes'));
let proclamationIds: string[] = [];
const raw = formData.get('proclamationIds');
if (typeof raw === 'string' && raw.length > 0) {
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
proclamationIds = parsed.filter((id): id is string => typeof id === 'string');
}
} catch {
proclamationIds = [];
}
}
const dynamicAudio = formData.get('dynamicAudio') === 'true';
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
}
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const [blocks, categories, groups, proclamations] = await Promise.all([
listBlocks(locals.user.id),
prisma.proclamationCategory.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
}),
prisma.proclamationGroup.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
}),
// Content is deliberately never selected: the portal only surfaces
// title/category/group.
prisma.proclamation.findMany({
select: { id: true, name: true, categoryId: true, groupId: true },
orderBy: { name: 'asc' }
})
]);
const blockViews = blocks.map((block) => ({
id: block.id,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio,
proclamations: block.proclamations.map((entry) => ({
id: entry.proclamation.id,
name: entry.proclamation.name,
categoryId: entry.proclamation.categoryId,
groupId: entry.proclamation.groupId
}))
}));
return { blocks: blockViews, categories, groups, proclamations };
};
export const actions: Actions = {
createBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const input = parseInput(await request.formData());
const result = await createBlock(locals.user.id, input);
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
updateBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const id = formData.get('id')?.toString();
if (!id) return fail(400, { error: 'Block not found.' });
const result = await updateBlock(locals.user.id, id, parseInput(formData));
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
deleteBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const id = (await request.formData()).get('id')?.toString();
if (!id) return fail(400, { error: 'Block not found.' });
const result = await deleteBlock(locals.user.id, id);
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
}
};
@@ -0,0 +1,100 @@
<script lang="ts">
import type { PageProps } from './$types';
import DaySchedule from '$lib/components/schedule/DaySchedule.svelte';
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
let { data }: PageProps = $props();
let modalOpen = $state(false);
let modalMode = $state<'create' | 'edit'>('create');
let editingId = $state<string | undefined>(undefined);
let initialStart = $state(9 * 60);
let initialEnd = $state(10 * 60);
let initialIds = $state<string[]>([]);
let initialDynamicAudio = $state(false);
let successMessage = $state('');
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function openCreate(startMinutes: number, endMinutes: number) {
modalMode = 'create';
editingId = undefined;
initialStart = startMinutes;
initialEnd = endMinutes;
initialIds = [];
initialDynamicAudio = false;
modalOpen = true;
}
function openEdit(block: {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string }[];
}) {
modalMode = 'edit';
editingId = block.id;
initialStart = block.startMinutes;
initialEnd = block.endMinutes;
initialIds = block.proclamations.map((proclamation) => proclamation.id);
initialDynamicAudio = block.dynamicAudio;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
}
function handleSaved() {
modalOpen = false;
successMessage = modalMode === 'edit' ? 'Block saved.' : 'Block created.';
clearTimeout(closeTimer);
closeTimer = setTimeout(() => (successMessage = ''), 1500);
}
</script>
<div>
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-100">
Schedule
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
Build your daily listening schedule. Up to 4 blocks per day.
</p>
</div>
<span
class="rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{data.blocks.length} of 4 blocks
</span>
</div>
{#if successMessage}
<div
class="mt-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{successMessage}
</div>
{/if}
<div class="mt-6">
<DaySchedule blocks={data.blocks} onCreate={openCreate} onEdit={openEdit} />
</div>
<BlockModal
open={modalOpen}
mode={modalMode}
blockId={editingId}
initialStartMinutes={initialStart}
initialEndMinutes={initialEnd}
initialProclamationIds={initialIds}
{initialDynamicAudio}
categories={data.categories}
groups={data.groups}
proclamations={data.proclamations}
existingBlocks={data.blocks}
onClose={closeModal}
onSaved={handleSaved}
/>
</div>
@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isActionFailure } from '@sveltejs/kit';
import { createFormRequest, createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
proclamationCategory: { findMany: vi.fn() },
proclamationGroup: { findMany: vi.fn() },
proclamation: { findMany: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
listBlocks: vi.fn(),
createBlock: vi.fn(),
updateBlock: vi.fn(),
deleteBlock: vi.fn()
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks } from '$lib/server/schedule';
import { actions, load } from './+page.server';
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const listBlocksMock = listBlocks as ReturnType<typeof vi.fn>;
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
const locals = createLocals(mockUser() as never);
beforeEach(() => {
vi.clearAllMocks();
(prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 2, name: 'Physical' }
]);
(prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, name: 'General' }
]);
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
listBlocksMock.mockResolvedValue([]);
});
describe('schedule load', () => {
it('rejects unauthenticated requests', async () => {
await expect(load({ locals: createLocals() } as never)).rejects.toMatchObject({
status: 403
});
});
it('never selects proclamation content', async () => {
await load({ locals } as never);
expect(procFindMany).toHaveBeenCalledWith(
expect.objectContaining({
select: { id: true, name: true, categoryId: true, groupId: true }
})
);
});
it('maps blocks to title/category/group only', async () => {
listBlocksMock.mockResolvedValue([
{
id: 'b1',
startMinutes: 360,
endMinutes: 480,
dynamicAudio: true,
proclamations: [
{
proclamation: {
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1,
content: '<p>secret</p>'
}
}
]
}
]);
const result = (await load({ locals } as never)) as {
blocks: {
id: string;
dynamicAudio: boolean;
proclamations: Record<string, unknown>[];
}[];
};
expect(result.blocks[0].dynamicAudio).toBe(true);
expect(result.blocks[0].proclamations[0]).toEqual({
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1
});
expect(Object.keys(result.blocks[0].proclamations[0])).not.toContain('content');
});
});
describe('schedule actions', () => {
it('createBlock rejects unauthenticated requests', async () => {
await expect(
actions.createBlock!({
request: createFormRequest({}),
locals: createLocals()
} as never)
).rejects.toMatchObject({ status: 403 });
});
it('createBlock surfaces the service error', async () => {
createBlockMock.mockResolvedValue({ ok: false, error: 'Select at least one proclamation.' });
const result = await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: '[]'
}),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.status).toBe(400);
expect(result.data).toEqual({ error: 'Select at least one proclamation.' });
}
});
it('createBlock parses the payload and succeeds', async () => {
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
const result = await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1', 'p2']),
dynamicAudio: 'true'
}),
locals
} as never);
expect(result).toEqual({ success: true });
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
startMinutes: 360,
endMinutes: 480,
proclamationIds: ['p1', 'p2'],
dynamicAudio: true
});
});
it('createBlock defaults dynamic audio to false when the field is absent', async () => {
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1'])
}),
locals
} as never);
expect(createBlockMock).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ dynamicAudio: false })
);
});
it('deleteBlock returns not found without an id', async () => {
const result = await actions.deleteBlock!({
request: createFormRequest({}),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.data).toEqual({ error: 'Block not found.' });
}
expect(deleteBlockMock).not.toHaveBeenCalled();
});
});
+3 -3
View File
@@ -14,9 +14,9 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!user) {
throw error(403, 'not authorized');
}
if (user.profiles.length === 0 && !url.pathname.startsWith('/account/setup')) {
// Skip redirect when already on /account/setup to avoid redirect loop
throw redirect(303, '/account/setup');
if (user.profiles.length === 0 && !url.pathname.startsWith('/profile/setup')) {
// Skip redirect when already on /profile/setup to avoid redirect loop
throw redirect(303, '/profile/setup');
}
return { userRoleId: user.roleId };
+1 -1
View File
@@ -4,7 +4,7 @@
let { children }: { children: Snippet } = $props();
</script>
<div class="min-h-screen bg-slate-50 text-slate-900">
<div class="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100">
<main class="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 sm:py-8">
{@render children()}
</main>
@@ -1,73 +0,0 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { rotateApiKey } from '$lib/server/api-key';
const SUPER = 1;
export const load: PageServerLoad = async ({ parent }) => {
const { adminUser } = await parent();
if (adminUser.roleId > SUPER) {
throw error(403, 'Only Super admins can manage API keys');
}
const now = new Date();
const activeKey = await prisma.apiKey.findFirst({
where: {
revokedAt: null,
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }]
},
select: { keyPrefix: true, createdAt: true, expiresAt: true },
orderBy: { createdAt: 'desc' }
});
const expiredCount = await prisma.apiKey.count({
where: {
OR: [{ revokedAt: { not: null, lte: now } }, { expiresAt: { not: null, lte: now } }]
}
});
return {
activeKey: activeKey
? {
prefix: activeKey.keyPrefix,
createdAt: activeKey.createdAt.toISOString(),
expiresAt: activeKey.expiresAt?.toISOString() ?? null
}
: null,
totalRotated: expiredCount
};
};
async function viewerRoleId(locals: App.Locals): Promise<number> {
if (!locals.user?.id) return 4;
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { roleId: true }
});
return user?.roleId ?? 4;
}
export const actions = {
rotate: async ({ locals }) => {
if ((await viewerRoleId(locals)) > SUPER) {
return { success: false, error: 'Not authorized' };
}
const result = await rotateApiKey(false);
return {
success: true,
newKey: result.plaintext,
mode: 'normal' as const,
expiresAt: result.previousExpiresAt?.toISOString() ?? null
};
},
rotateEmergency: async ({ locals }) => {
if ((await viewerRoleId(locals)) > SUPER) {
return { success: false, error: 'Not authorized' };
}
const result = await rotateApiKey(true);
return { success: true, newKey: result.plaintext, mode: 'emergency' as const };
}
};
@@ -1,143 +0,0 @@
<script lang="ts">
import type { PageProps } from './$types';
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
let { data }: PageProps = $props();
let newKey = $state('');
let mode = $state<'normal' | 'emergency' | null>(null);
let expiryDate = $state<string | null>(null);
let toastMessage = $state('');
function handleRotate(emergency: boolean): SubmitFunction {
return ({ cancel }) => {
const msg = emergency
? 'EMERGENCY ROTATION: The current key will be revoked IMMEDIATELY. Continue?'
: 'Rotate the API key? The current key will remain valid for 48 hours.';
if (!confirm(msg)) {
cancel();
return;
}
return async ({ result }) => {
if (result.type === 'success' && result.status === 200) {
if (result.data?.newKey) {
newKey = result.data.newKey;
mode = result.data.mode ?? null;
expiryDate = result.data.expiresAt ?? null;
}
} else {
toastMessage = 'Rotation failed';
}
};
};
}
function copyKey() {
navigator.clipboard.writeText(newKey);
toastMessage = 'Copied to clipboard';
}
function dismissKey() {
newKey = '';
mode = null;
expiryDate = null;
}
</script>
<div class="mx-auto w-full">
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h2 class="text-xl font-semibold text-slate-900">API Key Management</h2>
{#if data.activeKey}
<span
class="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-3 py-1 text-xs font-medium text-green-700"
>
Active
</span>
{/if}
</div>
{#if newKey}
<div class="mb-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<p class="text-sm font-medium text-slate-900">
{mode === 'emergency' ? 'Emergency Rotation Complete' : 'New API Key Generated'}
</p>
<p class="mt-1 text-sm text-slate-600">This key will not be shown again. Copy it now.</p>
<div class="mt-3 rounded-md bg-slate-100 px-3 py-2">
<code class="text-sm break-all text-slate-800">{newKey}</code>
</div>
<div class="mt-3 flex gap-2">
<button
type="button"
onclick={copyKey}
class="inline-flex h-10 items-center justify-center gap-1.5 rounded-md bg-indigo-600 px-4 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-700"
>
Copy to Clipboard
</button>
<button
type="button"
onclick={dismissKey}
class="inline-flex h-10 items-center justify-center rounded-md border border-slate-300 bg-white px-4 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50"
>
Dismiss
</button>
</div>
{#if mode === 'normal' && expiryDate}
<p class="mt-3 text-xs text-slate-500">
The previous key will remain valid until {new Date(expiryDate).toLocaleString()}
to give in-field devices time to update.
</p>
{/if}
</div>
{/if}
{#if toastMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{toastMessage}
</div>
{/if}
<div class="mb-4 rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<h3 class="text-base font-semibold text-slate-900">Current Key Status</h3>
{#if data.activeKey}
<div class="mt-3 space-y-1 text-sm text-slate-700">
<div>
Prefix: <code class="rounded bg-slate-100 px-1.5 py-0.5">{data.activeKey.prefix}</code>
</div>
<div>Created: {new Date(data.activeKey.createdAt).toLocaleString()}</div>
{#if data.activeKey.expiresAt}
<div>Expires: {new Date(data.activeKey.expiresAt).toLocaleString()}</div>
{/if}
</div>
{:else}
<p class="mt-2 text-sm text-slate-600">No active API key. Generate one below.</p>
{/if}
{#if data.totalRotated > 0}
<p class="mt-2 text-sm text-slate-600">
{data.totalRotated} previous key(s) have been rotated.
</p>
{/if}
</div>
<div class="flex flex-col gap-3 sm:flex-row">
<form method="POST" action="?/rotate" use:enhance={handleRotate(false)}>
<button
type="submit"
class="inline-flex h-10 items-center justify-center gap-1.5 rounded-md bg-indigo-600 px-4 text-sm font-medium text-white shadow-sm transition hover:bg-indigo-700"
>
Generate New API Key
</button>
</form>
<form method="POST" action="?/rotateEmergency" use:enhance={handleRotate(true)}>
<button
type="submit"
class="inline-flex h-10 items-center justify-center gap-1.5 rounded-md bg-red-600 px-4 text-sm font-medium text-white shadow-sm transition hover:bg-red-700"
>
Emergency Rotation
</button>
</form>
</div>
</div>
@@ -0,0 +1,110 @@
import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { SUPER, isStaff, roleId as viewerRoleId } from '$lib/server/roles';
import type { Prisma } from '$lib/generated/prisma/client';
const PAGE_SIZE = 50;
function parseDate(value: string): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export const load: PageServerLoad = async ({ locals, url }) => {
if (!isStaff(locals)) {
throw error(403, 'not authorized');
}
const viewerRole = viewerRoleId(locals);
const params = url.searchParams;
const q = params.get('q')?.trim() ?? '';
const role = params.get('role')?.trim() ?? '';
const action = params.get('action')?.trim() ?? '';
const entity = params.get('entity')?.trim() ?? '';
const page = params.get('page')?.trim() ?? '';
const from = params.get('from')?.trim() ?? '';
const to = params.get('to')?.trim() ?? '';
const sort = params.get('sort') === 'asc' ? 'asc' : 'desc';
const requestedPage = Math.max(1, Number.parseInt(params.get('p') ?? '1', 10) || 1);
// Visibility: a viewer sees activity from roles strictly beneath their own.
// Supers see everything (including other supers and system/API events),
// otherwise nobody could audit a super.
const visibility: Prisma.AuditLogWhereInput =
viewerRole > SUPER ? { roleId: { gt: viewerRole } } : {};
const filters: Prisma.AuditLogWhereInput[] = [];
if (q) {
filters.push({
OR: [
{ email: { contains: q, mode: 'insensitive' } },
{ userId: { contains: q, mode: 'insensitive' } },
{ roleName: { contains: q, mode: 'insensitive' } },
{ page: { contains: q, mode: 'insensitive' } },
{ action: { contains: q, mode: 'insensitive' } },
{ entity: { contains: q, mode: 'insensitive' } },
{ entityId: { contains: q, mode: 'insensitive' } }
]
});
}
if (role) filters.push({ roleId: Number(role) });
if (action) filters.push({ action });
if (entity) filters.push({ entity });
if (page) filters.push({ page: { contains: page, mode: 'insensitive' } });
if (from) {
const date = parseDate(from);
if (date) filters.push({ createdAt: { gte: date } });
}
if (to) {
const date = parseDate(to);
if (date) {
const end = new Date(date);
end.setHours(23, 59, 59, 999);
filters.push({ createdAt: { lte: end } });
}
}
const where: Prisma.AuditLogWhereInput = { AND: [visibility, ...filters] };
const [records, total, actionFacets, entityFacets, roleFacets] = await Promise.all([
prisma.auditLog.findMany({
where,
orderBy: { createdAt: sort },
skip: (requestedPage - 1) * PAGE_SIZE,
take: PAGE_SIZE
}),
prisma.auditLog.count({ where }),
prisma.auditLog.groupBy({ by: ['action'], where: visibility, orderBy: { action: 'asc' } }),
prisma.auditLog.groupBy({ by: ['entity'], where: visibility, orderBy: { entity: 'asc' } }),
prisma.auditLog.groupBy({
by: ['roleId', 'roleName'],
where: visibility,
orderBy: { roleId: 'asc' }
})
]);
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
return {
records,
total,
page: Math.min(requestedPage, totalPages),
totalPages,
pageSize: PAGE_SIZE,
filters: { q, role, action, entity, page, from, to, sort },
actionOptions: actionFacets.map((facet) => facet.action),
entityOptions: entityFacets
.map((facet) => facet.entity)
.filter((value): value is string => Boolean(value)),
roleOptions: roleFacets
.filter((facet) => facet.roleId !== null)
.map((facet) => ({
id: facet.roleId as number,
name: facet.roleName ?? `Role ${facet.roleId}`
})),
viewerRole
};
};
@@ -0,0 +1,338 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function formatJson(value: unknown): string {
if (value === null || value === undefined) return '—';
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function formatWhen(value: string | Date): string {
return new Date(value).toLocaleString();
}
const first = $derived(data.total === 0 ? 0 : (data.page - 1) * data.pageSize + 1);
const last = $derived(Math.min(data.page * data.pageSize, data.total));
</script>
<div class="mx-auto w-full">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-100">
Audit Log
</h1>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
Every database change, newest first. You can see activity from roles beneath your own.
</p>
</div>
<p class="text-sm text-slate-500 dark:text-slate-400">{first}–{last} of {data.total}</p>
</div>
<!-- Filters -->
<form
method="GET"
class="mt-6 rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="flex flex-col gap-1">
<label
for="q"
class="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400"
>Search</label
>
<input
id="q"
name="q"
type="search"
value={data.filters.q}
placeholder="User, email, page, action…"
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div class="flex flex-col gap-1">
<label
for="role"
class="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400"
>Role</label
>
<select
id="role"
name="role"
value={data.filters.role}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
>
<option value="">All roles</option>
{#each data.roleOptions as role (role.id)}
<option value={String(role.id)}>{role.name}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label
for="action"
class="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400"
>Action</label
>
<select
id="action"
name="action"
value={data.filters.action}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
>
<option value="">All actions</option>
{#each data.actionOptions as action (action)}
<option value={action}>{action}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label
for="entity"
class="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400"
>Entity</label
>
<select
id="entity"
name="entity"
value={data.filters.entity}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
>
<option value="">All entities</option>
{#each data.entityOptions as entity (entity)}
<option value={entity}>{entity}</option>
{/each}
</select>
</div>
<div class="flex flex-col gap-1">
<label
for="page"
class="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400"
>Page</label
>
<input
id="page"
name="page"
type="text"
value={data.filters.page}
placeholder="/admin/users"
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div class="flex flex-col gap-1">
<label
for="from"
class="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400"
>From</label
>
<input
id="from"
name="from"
type="date"
value={data.filters.from}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div class="flex flex-col gap-1">
<label
for="to"
class="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400"
>To</label
>
<input
id="to"
name="to"
type="date"
value={data.filters.to}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
/>
</div>
<div class="flex flex-col gap-1">
<label
for="sort"
class="text-xs font-medium tracking-wide text-slate-500 uppercase dark:text-slate-400"
>Order</label
>
<select
id="sort"
name="sort"
value={data.filters.sort}
class="h-10 rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500 dark:border-slate-700 dark:text-slate-100"
>
<option value="desc">Newest first</option>
<option value="asc">Oldest first</option>
</select>
</div>
</div>
<div class="mt-4 flex items-center justify-end gap-3">
<a
href={resolve('/admin/audit-log')}
class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Clear
</a>
<button
type="submit"
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Apply filters
</button>
</div>
</form>
<!-- Results -->
<div
class="mt-6 overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
>
<table class="w-full min-w-max text-left text-sm text-slate-700 dark:text-slate-300">
<thead>
<tr
class="border-b border-slate-200 bg-slate-50 text-xs font-semibold tracking-wide text-slate-500 uppercase dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400"
>
<th scope="col" class="px-4 py-3">When</th>
<th scope="col" class="px-4 py-3">User</th>
<th scope="col" class="px-4 py-3">Email</th>
<th scope="col" class="px-4 py-3">Role</th>
<th scope="col" class="px-4 py-3">Page</th>
<th scope="col" class="px-4 py-3">Action</th>
<th scope="col" class="px-4 py-3">Entity</th>
<th scope="col" class="px-4 py-3">Previous</th>
<th scope="col" class="px-4 py-3">Post</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{#each data.records as record (record.id)}
<tr class="align-top hover:bg-slate-50 dark:hover:bg-slate-800">
<td class="px-4 py-3 whitespace-nowrap text-slate-500 dark:text-slate-400">
{formatWhen(record.createdAt)}
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-600 dark:text-slate-400">
{record.userId ?? '—'}
</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-300">{record.email ?? '—'}</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-300"
>
{record.roleName ?? (record.roleId != null ? `Role ${record.roleId}` : 'System')}
</span>
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-600 dark:text-slate-400"
>{record.page}</td
>
<td class="px-4 py-3">
<span
class="inline-flex items-center rounded-full bg-indigo-50 px-2.5 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-950 dark:text-indigo-300"
>
{record.action}
</span>
</td>
<td class="px-4 py-3 text-slate-700 dark:text-slate-300">
{record.entity ?? '—'}{#if record.entityId}<span
class="text-slate-400 dark:text-slate-500">#{record.entityId}</span
>{/if}
</td>
<td class="max-w-xs px-4 py-3">
{#if record.before == null}
<span class="text-slate-400 dark:text-slate-500">—</span>
{:else}
<details>
<summary
class="cursor-pointer text-xs text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
>view</summary
>
<pre
class="mt-2 max-h-64 overflow-auto rounded-md bg-slate-50 p-2 text-xs whitespace-pre-wrap text-slate-700 dark:bg-slate-950 dark:text-slate-300">{formatJson(
record.before
)}</pre>
</details>
{/if}
</td>
<td class="max-w-xs px-4 py-3">
{#if record.after == null}
<span class="text-slate-400 dark:text-slate-500">—</span>
{:else}
<details>
<summary
class="cursor-pointer text-xs text-indigo-600 hover:text-indigo-700 dark:text-indigo-400 dark:hover:text-indigo-300"
>view</summary
>
<pre
class="mt-2 max-h-64 overflow-auto rounded-md bg-slate-50 p-2 text-xs whitespace-pre-wrap text-slate-700 dark:bg-slate-950 dark:text-slate-300">{formatJson(
record.after
)}</pre>
</details>
{/if}
</td>
</tr>
{:else}
<tr>
<td colspan="9" class="px-4 py-12 text-center text-slate-500 dark:text-slate-400"
>No events found.</td
>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Pagination -->
{#if data.totalPages > 1}
<div class="mt-4 flex items-center justify-between">
<p class="text-sm text-slate-500 dark:text-slate-400">
Page {data.page} of {data.totalPages}
</p>
<form method="GET" action={resolve('/admin/audit-log')} class="flex gap-2">
{#if data.filters.q}<input type="hidden" name="q" value={data.filters.q} />{/if}
{#if data.filters.role}<input type="hidden" name="role" value={data.filters.role} />{/if}
{#if data.filters.action}<input
type="hidden"
name="action"
value={data.filters.action}
/>{/if}
{#if data.filters.entity}<input
type="hidden"
name="entity"
value={data.filters.entity}
/>{/if}
{#if data.filters.page}<input type="hidden" name="page" value={data.filters.page} />{/if}
{#if data.filters.from}<input type="hidden" name="from" value={data.filters.from} />{/if}
{#if data.filters.to}<input type="hidden" name="to" value={data.filters.to} />{/if}
{#if data.filters.sort === 'asc'}<input type="hidden" name="sort" value="asc" />{/if}
{#if data.page > 1}
<button
type="submit"
name="p"
value={data.page - 1}
class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Previous
</button>
{/if}
{#if data.page < data.totalPages}
<button
type="submit"
name="p"
value={data.page + 1}
class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Next
</button>
{/if}
</form>
</div>
{/if}
</div>
@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, createUrl, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
auditLog: {
findMany: vi.fn(),
count: vi.fn(),
groupBy: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const findMany = prisma.auditLog.findMany as ReturnType<typeof vi.fn>;
const count = prisma.auditLog.count as ReturnType<typeof vi.fn>;
const groupBy = prisma.auditLog.groupBy as ReturnType<typeof vi.fn>;
const noAuth = () => load({ locals: createLocals(), url: createUrl('/admin/audit-log') } as any);
const loadAs = (roleId: number, params: Record<string, string> = {}) =>
load({
locals: createLocals(mockUser({ roleId }) as any),
url: createUrl('/admin/audit-log', params)
} as any);
describe('audit-log page server load', () => {
beforeEach(() => {
vi.clearAllMocks();
findMany.mockResolvedValue([]);
count.mockResolvedValue(0);
groupBy.mockResolvedValue([]);
});
it('rejects unauthenticated users with 403', async () => {
const err = await Promise.resolve(noAuth()).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('rejects regular users with 403', async () => {
const err = await Promise.resolve(loadAs(4)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('lets staff see only roles beneath them (users)', async () => {
await loadAs(3);
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { AND: [{ roleId: { gt: 3 } }] } })
);
});
it('lets admins see staff and users', async () => {
await loadAs(2);
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { AND: [{ roleId: { gt: 2 } }] } })
);
});
it('lets supers see everything, including other supers and system events', async () => {
await loadAs(1);
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { AND: [{}] } }));
});
it('applies search across the requested columns', async () => {
await loadAs(1, { q: 'jane' });
const arg = findMany.mock.calls[0][0] as { where: { AND: unknown[] } };
expect(arg.where.AND).toHaveLength(2);
expect(arg.where.AND[1]).toHaveProperty('OR');
});
it('applies column filters', async () => {
await loadAs(1, {
role: '4',
action: 'update',
entity: 'Profile',
page: '/admin/profiles',
from: '2026-01-01'
});
const arg = findMany.mock.calls[0][0] as { where: { AND: unknown[] } };
const combined = JSON.stringify(arg.where.AND);
expect(combined).toContain('"roleId":4');
expect(combined).toContain('"action":"update"');
expect(combined).toContain('"entity":"Profile"');
expect(combined).toContain('"/admin/profiles"');
expect(combined).toContain('"gte"');
});
it('paginates and reports totals', async () => {
findMany.mockResolvedValue([{ id: 'a' }]);
count.mockResolvedValue(120);
const result = (await loadAs(1, { p: '2' })) as {
page: number;
totalPages: number;
total: number;
};
expect(result.total).toBe(120);
expect(result.page).toBe(2);
expect(result.totalPages).toBe(3);
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 50, take: 50 }));
});
});
@@ -1,6 +1,7 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { sendMail } from '$lib/server/resend';
import { isStaff } from '$lib/server/roles';
import { error } from '@sveltejs/kit';
function generateLicenseKey(): string {
@@ -18,7 +19,7 @@ export const load: PageServerLoad = async () => {
export const actions = {
createKey: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
if (!isStaff(locals)) throw error(403, 'Not authorized');
const formData = await request.formData();
const email = formData.get('email')?.toString()?.trim().toLowerCase();
@@ -60,7 +61,7 @@ export const actions = {
return { success: true, action: 'createKey' };
},
replace: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
if (!isStaff(locals)) throw error(403, 'Not authorized');
const formData = await request.formData();
const keyId = formData.get('id')?.toString();
@@ -35,8 +35,20 @@
let replaceError = $state('');
let replaceSuccess = $state('');
let lastCreateForm = $state<ActionResult>(null);
let lastReplaceForm = $state<ActionResult>(null);
let lastCreateForm = $state.raw<ActionResult>(null);
let lastReplaceForm = $state.raw<ActionResult>(null);
let createTimer: ReturnType<typeof setTimeout> | undefined;
let replaceTimer: ReturnType<typeof setTimeout> | undefined;
function clearTimer(timer: 'create' | 'replace') {
const current = timer === 'create' ? createTimer : replaceTimer;
if (current) {
clearTimeout(current);
if (timer === 'create') createTimer = undefined;
else replaceTimer = undefined;
}
}
$effect(() => {
if (!form) return;
@@ -48,9 +60,10 @@
email = '';
createError = '';
createSuccess = 'License key created and emailed';
setTimeout(() => {
createTimer = setTimeout(() => {
createOpen = false;
createSuccess = '';
createTimer = undefined;
}, 1200);
} else {
createSuccess = '';
@@ -62,9 +75,10 @@
if (form.success) {
replaceError = '';
replaceSuccess = 'License key replaced';
setTimeout(() => {
replaceTimer = setTimeout(() => {
replaceOpen = false;
replaceSuccess = '';
replaceTimer = undefined;
}, 1200);
} else {
replaceSuccess = '';
@@ -74,25 +88,30 @@
});
function openCreate() {
lastCreateForm = null;
clearTimer('create');
lastCreateForm = form;
email = '';
createError = '';
createSuccess = '';
createOpen = true;
}
function openReplace(record: Record<string, unknown>) {
clearTimer('replace');
replaceRecord = record;
lastReplaceForm = null;
lastReplaceForm = form;
replaceError = '';
replaceSuccess = '';
replaceOpen = true;
}
function closeCreate() {
clearTimer('create');
createOpen = false;
}
function closeReplace() {
clearTimer('replace');
replaceOpen = false;
replaceRecord = null;
}
@@ -114,20 +133,25 @@
<AdminModal title="Create License Key" open={createOpen} onClose={closeCreate}>
{#if createSuccess}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{createSuccess}
</div>
{/if}
{#if createError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
>
{createError}
</div>
{/if}
<form method="POST" action="?/createKey" use:enhance class="flex flex-col gap-4">
<div>
<label for="field-email" class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<label
for="field-email"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Email</label
>
<input
id="field-email"
type="email"
@@ -135,9 +159,9 @@
required
bind:value={email}
placeholder="user@example.com"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
<p class="mt-1 text-sm text-slate-500">
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
If no account exists for this email yet, the key will be attached when they register.
</p>
</div>
@@ -146,7 +170,7 @@
<button
type="button"
onclick={closeCreate}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
@@ -163,23 +187,25 @@
<AdminModal title="Replace License Key" open={replaceOpen} onClose={closeReplace}>
{#if replaceSuccess}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{replaceSuccess}
</div>
{/if}
{#if replaceError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
>
{replaceError}
</div>
{/if}
{#if replaceRecord}
<div class="flex flex-col gap-4">
<p class="text-sm text-slate-700">
<p class="text-sm text-slate-700 dark:text-slate-300">
Generate a new key to replace <strong>{String(replaceRecord.key)}</strong>?
</p>
<p class="text-sm text-slate-500">
<p class="text-sm text-slate-500 dark:text-slate-400">
The existing key will be deleted and a new one will be created.
</p>
@@ -189,7 +215,7 @@
<button
type="button"
onclick={closeReplace}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
@@ -48,11 +48,11 @@ describe('admin license-keys createKey action', () => {
const req = createFormRequest({ email: 'jane@example.com' });
const err = await actions.createKey({ request: req, locals } as any).catch((e) => e);
expect(isHttpError(err, 401)).toBe(true);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns error when email is missing', async () => {
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 3 }) as any);
const req = createFormRequest({});
const result = await actions.createKey({ request: req, locals } as any);
@@ -65,7 +65,7 @@ describe('admin license-keys createKey action', () => {
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockResolvedValue({ id: 'email-1' });
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 3 }) as any);
const req = createFormRequest({ email: 'nobody@example.com' });
const result = await actions.createKey({ request: req, locals } as any);
@@ -86,7 +86,7 @@ describe('admin license-keys createKey action', () => {
});
it('creates a license key and sends email with license_key variable', async () => {
const user = mockUser();
const user = mockUser({ roleId: 3 });
const profile = { firstName: 'Jane' };
mockFindUnique.mockResolvedValue(user);
@@ -117,7 +117,7 @@ describe('admin license-keys createKey action', () => {
});
it('sends email with license_key even when profile is missing', async () => {
const user = mockUser();
const user = mockUser({ roleId: 3 });
mockFindUnique.mockResolvedValue(user);
mockProfileFindFirst.mockResolvedValue(null);
@@ -136,7 +136,7 @@ describe('admin license-keys createKey action', () => {
});
it('still returns success when email sending fails', async () => {
const user = mockUser();
const user = mockUser({ roleId: 3 });
mockFindUnique.mockResolvedValue(user);
mockProfileFindFirst.mockResolvedValue({ firstName: 'Jane' });
@@ -166,11 +166,11 @@ describe('admin license-keys replace action', () => {
const req = createFormRequest({ id: 'k1' });
const err = await actions.replace({ request: req, locals } as any).catch((e) => e);
expect(isHttpError(err, 401)).toBe(true);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns error when id is missing', async () => {
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 3 }) as any);
const req = createFormRequest({});
const result = await actions.replace({ request: req, locals } as any);
@@ -180,7 +180,7 @@ describe('admin license-keys replace action', () => {
it('returns error when the key does not exist', async () => {
mockLicenseKeyFindUnique.mockResolvedValue(null);
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 3 }) as any);
const req = createFormRequest({ id: 'missing' });
const result = await actions.replace({ request: req, locals } as any);
@@ -197,7 +197,7 @@ describe('admin license-keys replace action', () => {
mockLicenseKeyDelete.mockResolvedValue({});
mockLicenseKeyCreate.mockResolvedValue({});
const locals = createLocals(mockUser() as any);
const locals = createLocals(mockUser({ roleId: 3 }) as any);
const req = createFormRequest({ id: 'k1' });
const result = await actions.replace({ request: req, locals } as any);
@@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { isAdmin, isStaff } from '$lib/server/roles';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
@@ -17,7 +18,11 @@ export const load: PageServerLoad = async () => {
};
export const actions = {
upsert: async ({ request }) => {
upsert: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, action: 'upsert', error: 'Not authorized' };
}
const formData = await request.formData();
const form = await superValidate(formData, zod4(CategorySchema));
if (!form.valid) {
@@ -58,7 +63,11 @@ export const actions = {
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
delete: async ({ request, locals }) => {
if (!isAdmin(locals)) {
return { success: false, error: 'Not authorized' };
}
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
@@ -77,7 +86,11 @@ export const actions = {
return { success: true, action: 'delete' };
},
reorder: async ({ request }) => {
reorder: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, error: 'Not authorized' };
}
const formData = await request.formData();
const raw = formData.get('ids')?.toString();
if (!raw) return { success: false, error: 'Missing ids' };
@@ -24,17 +24,23 @@
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
let lastForm = $state.raw<ActionResult>(null);
$effect(() => {
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function clearCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = undefined;
}
});
}
function resetModalState() {
clearCloseTimer();
formError = '';
fieldErrors = {};
successMessage = '';
}
$effect(() => {
if (!form || form === lastForm) return;
@@ -44,9 +50,10 @@
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Category updated' : 'Category created';
setTimeout(() => {
closeTimer = setTimeout(() => {
modalOpen = false;
successMessage = '';
closeTimer = undefined;
}, 1200);
} else {
successMessage = '';
@@ -63,18 +70,23 @@
}
function openCreate() {
resetModalState();
editingRecord = null;
lastForm = null;
name = '';
lastForm = form;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
resetModalState();
editingRecord = record;
lastForm = null;
name = String(record.name ?? '');
lastForm = form;
modalOpen = true;
}
function closeModal() {
clearCloseTimer();
modalOpen = false;
editingRecord = null;
}
@@ -104,13 +116,15 @@
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
>
{formError}
</div>
{/if}
@@ -121,17 +135,20 @@
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Name</label
>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{fieldErrors.name}</p>
{/if}
</div>
@@ -139,7 +156,7 @@
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
@@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { isAdmin, isStaff } from '$lib/server/roles';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
@@ -17,7 +18,11 @@ export const load: PageServerLoad = async () => {
};
export const actions = {
upsert: async ({ request }) => {
upsert: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, action: 'upsert', error: 'Not authorized' };
}
const formData = await request.formData();
const form = await superValidate(formData, zod4(GroupSchema));
if (!form.valid) {
@@ -58,7 +63,11 @@ export const actions = {
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
delete: async ({ request, locals }) => {
if (!isAdmin(locals)) {
return { success: false, error: 'Not authorized' };
}
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
@@ -77,7 +86,11 @@ export const actions = {
return { success: true, action: 'delete' };
},
reorder: async ({ request }) => {
reorder: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, error: 'Not authorized' };
}
const formData = await request.formData();
const raw = formData.get('ids')?.toString();
if (!raw) return { success: false, error: 'Missing ids' };
@@ -24,17 +24,23 @@
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
let lastForm = $state.raw<ActionResult>(null);
$effect(() => {
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function clearCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = undefined;
}
});
}
function resetModalState() {
clearCloseTimer();
formError = '';
fieldErrors = {};
successMessage = '';
}
$effect(() => {
if (!form || form === lastForm) return;
@@ -44,9 +50,10 @@
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Group updated' : 'Group created';
setTimeout(() => {
closeTimer = setTimeout(() => {
modalOpen = false;
successMessage = '';
closeTimer = undefined;
}, 1200);
} else {
successMessage = '';
@@ -63,18 +70,23 @@
}
function openCreate() {
resetModalState();
editingRecord = null;
lastForm = null;
name = '';
lastForm = form;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
resetModalState();
editingRecord = record;
lastForm = null;
name = String(record.name ?? '');
lastForm = form;
modalOpen = true;
}
function closeModal() {
clearCloseTimer();
modalOpen = false;
editingRecord = null;
}
@@ -104,13 +116,15 @@
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
>
{formError}
</div>
{/if}
@@ -121,17 +135,20 @@
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Name</label
>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{fieldErrors.name}</p>
{/if}
</div>
@@ -139,7 +156,7 @@
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
@@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { isSuper } from '$lib/server/roles';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
@@ -13,10 +14,6 @@ const ProclamationSchema = z.object({
content: z.string().optional().default('')
});
function isSuper(locals: App.Locals): boolean {
return ((locals.user as { roleId?: number } | null)?.roleId ?? 4) <= 1;
}
export const load: PageServerLoad = async ({ locals }) => {
const [proclamations, frequencies, categories, groups] = await Promise.all([
prisma.proclamation.findMany({
@@ -46,26 +46,28 @@
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
let lastForm = $state.raw<ActionResult>(null);
let element = $state<HTMLElement>();
let editor: Editor | null = null;
let contentKey = $derived(editingRecord?.id ?? 'new');
$effect(() => {
const record = editingRecord ?? {};
name = String(record.name ?? '');
solfeggioFrequencyId = String(record.solfeggioFrequencyId ?? '');
categoryId = String(record.categoryId ?? '');
groupId = String(record.groupId ?? '');
content = typeof record.content === 'string' ? record.content : '';
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function clearCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = undefined;
}
});
}
function resetModalState() {
clearCloseTimer();
formError = '';
fieldErrors = {};
successMessage = '';
}
$effect(() => {
if (!form || form === lastForm) return;
@@ -75,6 +77,11 @@
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Proclamation updated' : 'Proclamation created';
closeTimer = setTimeout(() => {
modalOpen = false;
successMessage = '';
closeTimer = undefined;
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
@@ -133,18 +140,31 @@
}
function openCreate() {
resetModalState();
editingRecord = null;
lastForm = null;
name = '';
solfeggioFrequencyId = '';
categoryId = '';
groupId = '';
content = '';
lastForm = form;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
resetModalState();
editingRecord = record;
lastForm = null;
name = String(record.name ?? '');
solfeggioFrequencyId = String(record.solfeggioFrequencyId ?? '');
categoryId = String(record.categoryId ?? '');
groupId = String(record.groupId ?? '');
content = typeof record.content === 'string' ? record.content : '';
lastForm = form;
modalOpen = true;
}
function closeModal() {
clearCloseTimer();
modalOpen = false;
editingRecord = null;
}
@@ -172,13 +192,15 @@
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
>
{formError}
</div>
{/if}
@@ -189,31 +211,34 @@
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Name</label
>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label
for="field-solfeggioFrequencyId"
class="mb-1 block text-sm font-medium text-slate-700">Frequency</label
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Frequency</label
>
<select
id="field-solfeggioFrequencyId"
name="solfeggioFrequencyId"
required
bind:value={solfeggioFrequencyId}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
<option value="">-- Select Frequency --</option>
{#each data.frequencies as freq (String(freq.id))}
@@ -221,20 +246,23 @@
{/each}
</select>
{#if fieldErrors.solfeggioFrequencyId}
<p class="mt-1 text-sm text-red-600">{fieldErrors.solfeggioFrequencyId}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">
{fieldErrors.solfeggioFrequencyId}
</p>
{/if}
</div>
<div>
<label for="field-categoryId" class="mb-1 block text-sm font-medium text-slate-700"
>Category</label
<label
for="field-categoryId"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Category</label
>
<select
id="field-categoryId"
name="categoryId"
required
bind:value={categoryId}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
<option value="">-- Select Category --</option>
{#each data.categories as cat (String(cat.id))}
@@ -242,20 +270,21 @@
{/each}
</select>
{#if fieldErrors.categoryId}
<p class="mt-1 text-sm text-red-600">{fieldErrors.categoryId}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{fieldErrors.categoryId}</p>
{/if}
</div>
<div>
<label for="field-groupId" class="mb-1 block text-sm font-medium text-slate-700"
>Group</label
<label
for="field-groupId"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Group</label
>
<select
id="field-groupId"
name="groupId"
required
bind:value={groupId}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
<option value="">-- Select Group --</option>
{#each data.groups as group (String(group.id))}
@@ -263,42 +292,45 @@
{/each}
</select>
{#if fieldErrors.groupId}
<p class="mt-1 text-sm text-red-600">{fieldErrors.groupId}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{fieldErrors.groupId}</p>
{/if}
</div>
<div>
<label for="field-content" class="mb-1 block text-sm font-medium text-slate-700"
>Content</label
<label
for="field-content"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Content</label
>
<div class="rounded-md border border-slate-300 bg-white">
<div class="flex gap-1 border-b border-slate-200 px-2 py-1.5">
<div
class="rounded-md border border-slate-300 bg-white dark:border-slate-700 dark:bg-slate-900"
>
<div class="flex gap-1 border-b border-slate-200 px-2 py-1.5 dark:border-slate-800">
<button
type="button"
onclick={toggleBold}
title="Bold"
class="rounded px-2 py-1 text-sm font-bold text-slate-600 transition hover:bg-slate-100"
class="rounded px-2 py-1 text-sm font-bold text-slate-600 transition hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
>B</button
>
<button
type="button"
onclick={toggleItalic}
title="Italic"
class="rounded px-2 py-1 text-sm text-slate-600 italic transition hover:bg-slate-100"
class="rounded px-2 py-1 text-sm text-slate-600 italic transition hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
>I</button
>
<button
type="button"
onclick={toggleHighlight}
title="Highlight"
class="rounded px-2 py-1 text-sm text-slate-600 transition hover:bg-slate-100"
class="rounded px-2 py-1 text-sm text-slate-600 transition hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
>H</button
>
<button
type="button"
onclick={setColor}
title="Cycle text color"
class="rounded px-2 py-1 text-sm text-slate-600 transition hover:bg-slate-100"
class="rounded px-2 py-1 text-sm text-slate-600 transition hover:bg-slate-100 dark:text-slate-400 dark:hover:bg-slate-800"
>A</button
>
</div>
@@ -309,13 +341,13 @@
role="textbox"
tabindex="0"
contenteditable="true"
class="px-3 py-2 text-sm text-slate-900 focus:outline-none"
class="px-3 py-2 text-sm text-slate-900 focus:outline-none dark:text-slate-100"
></div>
{/key}
<input type="hidden" name="content" value={content} />
</div>
{#if fieldErrors.content}
<p class="mt-1 text-sm text-red-600">{fieldErrors.content}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{fieldErrors.content}</p>
{/if}
</div>
@@ -323,7 +355,7 @@
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
@@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { isAdmin, isStaff } from '$lib/server/roles';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
@@ -21,6 +22,7 @@ const ProfileSchema = z.object({
});
const AddressSchema = z.object({
id: z.string().optional(),
profileId: z.string().min(1),
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
@@ -45,7 +47,11 @@ export const load: PageServerLoad = async () => {
};
export const actions = {
upsert: async ({ request }) => {
upsert: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, action: 'upsert', error: 'Not authorized' };
}
const formData = await request.formData();
const form = await superValidate(formData, zod4(ProfileSchema));
if (!form.valid) {
@@ -112,14 +118,22 @@ export const actions = {
}
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
delete: async ({ request, locals }) => {
if (!isAdmin(locals)) {
return { success: false, action: 'delete', error: 'Not authorized' };
}
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
await prisma.profile.delete({ where: { id: rawId } });
return { success: true, action: 'delete' };
},
upsertAddress: async ({ request }) => {
upsertAddress: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, action: 'upsertAddress', addressError: 'Not authorized' };
}
const formData = await request.formData();
const form = await superValidate(formData, zod4(AddressSchema));
if (!form.valid) {
@@ -134,7 +148,7 @@ export const actions = {
};
}
const { profileId, isDefaultMailing, isDefaultBilling, isDefaultShipping, ...addressData } =
const { id, profileId, isDefaultMailing, isDefaultBilling, isDefaultShipping, ...addressData } =
form.data;
if (!profileId) {
@@ -168,7 +182,36 @@ export const actions = {
isDefaultShipping
};
if (id) {
const existing = await prisma.address.findFirst({ where: { id, profileId } });
if (!existing) {
return { success: false, action: 'upsertAddress', addressError: 'Address not found' };
}
const updated = await prisma.address.update({ where: { id }, data: payload });
return { success: true, action: 'upsertAddress', address: updated };
}
const created = await prisma.address.create({ data: payload });
return { success: true, action: 'upsertAddress', address: created };
},
deleteAddress: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, action: 'deleteAddress', addressError: 'Not authorized' };
}
const formData = await request.formData();
const id = formData.get('id')?.toString();
if (!id) {
return { success: false, action: 'deleteAddress', addressError: 'Missing address id' };
}
try {
await prisma.address.delete({ where: { id } });
} catch (err) {
console.error('Address delete error:', err);
return { success: false, action: 'deleteAddress', addressError: 'Failed to delete address' };
}
return { success: true, action: 'deleteAddress', addressId: id };
}
};
@@ -23,9 +23,19 @@
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
let lastForm = $state.raw<ActionResult>(null);
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function clearCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = undefined;
}
}
$effect(() => {
clearCloseTimer();
if (open) {
formError = '';
fieldErrors = {};
@@ -37,11 +47,19 @@
if (!form || form === lastForm) return;
lastForm = form;
// Consume results even while closed so a stale result is never
// replayed when the modal is next opened, but only show feedback
// while this modal is actually open.
if (!open) return;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = 'Profile created';
setTimeout(() => onClose?.(), 1200);
closeTimer = setTimeout(() => {
closeTimer = undefined;
onClose?.();
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
@@ -53,13 +71,15 @@
<AdminModal title="Create Profile" {open} {onClose}>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
>
{formError}
</div>
{/if}
@@ -71,7 +91,7 @@
<button
type="button"
onclick={onClose}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
@@ -1,4 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import ProfileFields from './ProfileFields.svelte';
@@ -10,6 +11,7 @@
fieldErrors?: Record<string, string>;
addressError?: string;
address?: Record<string, unknown>;
addressId?: string;
} | null;
let {
@@ -31,6 +33,7 @@
let addressError = $state('');
let successMessage = $state('');
let addressFormOpen = $state(false);
let editingAddressId = $state<string | null>(null);
let addresses = $state<Record<string, unknown>[]>([]);
let addrStreet = $state('');
@@ -43,9 +46,12 @@
let addrShipping = $state(false);
let lastRecordId = $state<string | null>(null);
let lastForm = $state<ActionResult>(null);
// Capture the form result as it is when this modal mounts so a stale
// result from a previous record is not replayed on open.
let lastForm = $state.raw<ActionResult>(untrack(() => form ?? null));
function resetAddressFields() {
editingAddressId = null;
addrStreet = '';
addrCity = '';
addrState = '';
@@ -86,7 +92,11 @@
switch (form.action) {
case 'upsertAddress':
if (form.success && form.address) {
addresses = [...addresses, form.address];
const savedId = String(form.address.id ?? '');
const exists = addresses.some((a) => String(a.id ?? '') === savedId);
addresses = exists
? addresses.map((a) => (String(a.id ?? '') === savedId ? form.address! : a))
: [...addresses, form.address];
addressFormOpen = false;
addressError = '';
successMessage = 'Address saved';
@@ -96,6 +106,16 @@
addressError = form.addressError ?? '';
}
break;
case 'deleteAddress':
if (form.success) {
addresses = addresses.filter((a) => String(a.id ?? '') !== String(form.addressId ?? ''));
addressError = '';
successMessage = 'Address deleted';
} else {
successMessage = '';
addressError = form.addressError ?? '';
}
break;
case 'upsert':
default:
if (form.success) {
@@ -116,18 +136,35 @@
addressError = '';
if (addressFormOpen) resetAddressFields();
}
function openEditAddress(addr: Record<string, unknown>) {
resetAddressFields();
editingAddressId = String(addr.id ?? '');
addrStreet = String(addr.street ?? '');
addrCity = String(addr.city ?? '');
addrState = String(addr.state ?? '');
addrPostal = String(addr.postalCode ?? '');
addrCountry = String(addr.country ?? '');
addrMailing = Boolean(addr.isDefaultMailing);
addrBilling = Boolean(addr.isDefaultBilling);
addrShipping = Boolean(addr.isDefaultShipping);
addressError = '';
addressFormOpen = true;
}
</script>
<AdminModal title="Edit Profile" {open} {onClose} {canDelete} recordId={String(record.id ?? '')}>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
>
{formError}
</div>
{/if}
@@ -141,7 +178,7 @@
<button
type="button"
onclick={onClose}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
@@ -154,13 +191,13 @@
</div>
</form>
<div class="mt-8 border-t border-slate-200 pt-6">
<div class="mt-8 border-t border-slate-200 pt-6 dark:border-slate-800">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900">Addresses</h3>
<h3 class="text-sm font-semibold text-slate-900 dark:text-slate-100">Addresses</h3>
<button
type="button"
onclick={toggleAddressForm}
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
{addressFormOpen ? 'Cancel' : 'Add Address +'}
</button>
@@ -169,14 +206,21 @@
{#if addressFormOpen}
<form method="POST" action="?/upsertAddress" use:enhance class="mt-4 flex flex-col gap-4">
<input type="hidden" name="profileId" value={String(record.id ?? '')} />
{#if editingAddressId}
<input type="hidden" name="id" value={editingAddressId} />
{/if}
{#if addressError}
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
<div
class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
>
{addressError}
</div>
{/if}
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="addr-street" class="mb-1 block text-sm font-medium text-slate-700"
<label
for="addr-street"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>Street</label
>
<input
@@ -185,11 +229,13 @@
name="street"
required
bind:value={addrStreet}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label for="addr-city" class="mb-1 block text-sm font-medium text-slate-700">City</label
<label
for="addr-city"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">City</label
>
<input
id="addr-city"
@@ -197,12 +243,13 @@
name="city"
required
bind:value={addrCity}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label for="addr-state" class="mb-1 block text-sm font-medium text-slate-700"
>State</label
<label
for="addr-state"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">State</label
>
<input
id="addr-state"
@@ -210,11 +257,13 @@
name="state"
required
bind:value={addrState}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label for="addr-postal" class="mb-1 block text-sm font-medium text-slate-700"
<label
for="addr-postal"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>Postal Code</label
>
<input
@@ -223,11 +272,13 @@
name="postalCode"
required
bind:value={addrPostal}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
</div>
<div>
<label for="addr-country" class="mb-1 block text-sm font-medium text-slate-700"
<label
for="addr-country"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>Country</label
>
<input
@@ -236,11 +287,11 @@
name="country"
required
bind:value={addrCountry}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
</div>
</div>
<div class="flex flex-wrap gap-4 text-sm text-slate-700">
<div class="flex flex-wrap gap-4 text-sm text-slate-700 dark:text-slate-300">
<label class="flex items-center gap-2">
<input type="checkbox" name="isDefaultMailing" bind:checked={addrMailing} />
Default Mailing
@@ -257,20 +308,20 @@
<div class="flex justify-end">
<button
type="submit"
class="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700"
class="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700 dark:bg-slate-700 dark:hover:bg-slate-600"
>
Save Address
{editingAddressId ? 'Update Address' : 'Save Address'}
</button>
</div>
</form>
{/if}
{#if addresses.length > 0}
<div class="mt-4 overflow-x-auto rounded-md border border-slate-200">
<table class="w-full min-w-max text-left text-sm text-slate-700">
<div class="mt-4 overflow-x-auto rounded-md border border-slate-200 dark:border-slate-800">
<table class="w-full min-w-max text-left text-sm text-slate-700 dark:text-slate-300">
<thead>
<tr
class="border-b border-slate-200 bg-slate-100 text-xs font-semibold tracking-wide text-slate-500"
class="border-b border-slate-200 bg-slate-100 text-xs font-semibold tracking-wide text-slate-500 dark:border-slate-800 dark:bg-slate-800 dark:text-slate-400"
>
<th scope="col" class="px-3 py-2">Street</th>
<th scope="col" class="px-3 py-2">City</th>
@@ -280,11 +331,12 @@
<th scope="col" class="px-3 py-2">M</th>
<th scope="col" class="px-3 py-2">B</th>
<th scope="col" class="px-3 py-2">S</th>
<th scope="col" class="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody>
{#each addresses as addr, i (String(addr.id ?? i))}
<tr class="bg-white">
<tr class="bg-white dark:bg-slate-900">
<td class="px-3 py-2 whitespace-nowrap">{addr.street as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.city as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.state as string}</td>
@@ -293,13 +345,33 @@
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultMailing ? '✓' : ''}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultBilling ? '✓' : ''}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultShipping ? '✓' : ''}</td>
<td class="px-3 py-2 whitespace-nowrap">
<div class="flex gap-2">
<button
type="button"
onclick={() => openEditAddress(addr)}
class="rounded-md border border-slate-300 bg-white px-2 py-1 text-xs font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Edit
</button>
<form method="POST" action="?/deleteAddress" use:enhance>
<input type="hidden" name="id" value={String(addr.id ?? '')} />
<button
type="submit"
class="rounded-md border border-red-300 bg-white px-2 py-1 text-xs font-medium text-red-600 transition hover:bg-red-50 dark:border-red-800 dark:bg-slate-900 dark:text-red-400 dark:hover:bg-red-950/50"
>
Delete
</button>
</form>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="mt-2 text-sm text-slate-600">No addresses on file.</p>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">No addresses on file.</p>
{/if}
</div>
</AdminModal>
@@ -24,7 +24,10 @@
<div class="flex flex-col gap-4">
<div>
<label for="field-email" class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<label
for="field-email"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Email</label
>
<input
id="field-email"
type="email"
@@ -32,16 +35,17 @@
required
bind:value={email}
placeholder="user@example.com"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{#if fieldErrors.email}
<p class="mt-1 text-sm text-red-600">{fieldErrors.email}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{fieldErrors.email}</p>
{/if}
</div>
<div>
<label for="field-firstName" class="mb-1 block text-sm font-medium text-slate-700"
>First Name</label
<label
for="field-firstName"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">First Name</label
>
<input
id="field-firstName"
@@ -49,16 +53,17 @@
name="firstName"
required
bind:value={firstName}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{#if fieldErrors.firstName}
<p class="mt-1 text-sm text-red-600">{fieldErrors.firstName}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{fieldErrors.firstName}</p>
{/if}
</div>
<div>
<label for="field-lastName" class="mb-1 block text-sm font-medium text-slate-700"
>Last Name</label
<label
for="field-lastName"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Last Name</label
>
<input
id="field-lastName"
@@ -66,15 +71,18 @@
name="lastName"
required
bind:value={lastName}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{#if fieldErrors.lastName}
<p class="mt-1 text-sm text-red-600">{fieldErrors.lastName}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{fieldErrors.lastName}</p>
{/if}
</div>
<div>
<label for="field-phoneNumber" class="mb-1 block text-sm font-medium text-slate-700">
<label
for="field-phoneNumber"
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>
Phone Number
</label>
<input
@@ -83,10 +91,10 @@
name="phoneNumber"
required
bind:value={phoneNumber}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
/>
{#if fieldErrors.phoneNumber}
<p class="mt-1 text-sm text-red-600">{fieldErrors.phoneNumber}</p>
<p class="mt-1 text-sm text-red-600 dark:text-red-400">{fieldErrors.phoneNumber}</p>
{/if}
</div>
</div>
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLocals, createFormRequest } from '$lib/test-utils';
import { createLocals, createFormRequest, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
@@ -14,6 +14,12 @@ vi.mock('$lib/db', () => ({
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn()
},
address: {
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
}));
@@ -29,6 +35,10 @@ const mockProfileDelete = prisma.profile.delete as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockUserFindFirst = prisma.user.findFirst as ReturnType<typeof vi.fn>;
const mockUserUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
const mockAddressFindFirst = prisma.address.findFirst as ReturnType<typeof vi.fn>;
const mockAddressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
const mockAddressUpdate = prisma.address.update as ReturnType<typeof vi.fn>;
const mockAddressDelete = prisma.address.delete as ReturnType<typeof vi.fn>;
const validFields = {
email: 'jane@example.com',
@@ -41,6 +51,9 @@ function makeProfileRequest(overrides: Record<string, string> = {}) {
return createFormRequest({ ...validFields, ...overrides });
}
const staff = () => createLocals(mockUser({ roleId: 3 }) as any);
const admin = () => createLocals(mockUser({ roleId: 2 }) as any);
describe('profiles admin page server', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -73,7 +86,10 @@ describe('profiles admin page server', () => {
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: validFields.email });
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
const result = await actions.upsert({
request: makeProfileRequest(),
locals: staff()
} as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { email: validFields.email } });
@@ -86,7 +102,10 @@ describe('profiles admin page server', () => {
mockUserFindUnique.mockResolvedValue(null);
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
const result = await actions.upsert({
request: makeProfileRequest(),
locals: staff()
} as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProfileCreate).toHaveBeenCalledWith({ data: validFields });
@@ -96,7 +115,10 @@ describe('profiles admin page server', () => {
mockUserFindUnique.mockResolvedValue(null);
mockProfileCreate.mockRejectedValue({ code: 'P2002' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
const result = await actions.upsert({
request: makeProfileRequest(),
locals: staff()
} as any);
expect(result).toEqual({
success: false,
@@ -119,7 +141,7 @@ describe('profiles admin page server', () => {
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ email: 'new@example.com', id: 'p1' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserUpdate).toHaveBeenCalledWith({
@@ -142,7 +164,7 @@ describe('profiles admin page server', () => {
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ id: 'p1' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindFirst).not.toHaveBeenCalled();
@@ -159,7 +181,7 @@ describe('profiles admin page server', () => {
mockUserFindFirst.mockResolvedValue({ id: 'user-2' });
const request = makeProfileRequest({ email: 'taken@example.com', id: 'p1' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result).toEqual({
success: false,
@@ -181,7 +203,7 @@ describe('profiles admin page server', () => {
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ id: 'p1' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProfileUpdate).toHaveBeenCalledWith({
@@ -194,7 +216,7 @@ describe('profiles admin page server', () => {
describe('upsert - validation', () => {
it('returns field errors for invalid input', async () => {
const request = makeProfileRequest({ email: 'not-an-email' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result.success).toBe(false);
expect(result.action).toBe('upsert');
@@ -205,7 +227,7 @@ describe('profiles admin page server', () => {
mockProfileFindUnique.mockResolvedValue(null);
const request = makeProfileRequest({ id: 'missing' });
const result = await actions.upsert({ request } as any);
const result = await actions.upsert({ request, locals: staff() } as any);
expect(result).toEqual({ success: false, action: 'upsert', error: 'Record not found' });
});
@@ -215,7 +237,7 @@ describe('profiles admin page server', () => {
it('deletes the profile by id', async () => {
mockProfileDelete.mockResolvedValue({});
const locals = createLocals();
const locals = admin();
const req = createFormRequest({ id: 'p1' });
const result = await actions.delete({ request: req, locals } as any);
@@ -224,10 +246,138 @@ describe('profiles admin page server', () => {
});
it('returns an error when id is missing', async () => {
const locals = createLocals();
const locals = admin();
const result = await actions.delete({ request: createFormRequest({}), locals } as any);
expect(result).toEqual({ success: false, error: 'Missing id' });
});
});
describe('upsertAddress', () => {
const addressFields = {
profileId: 'p1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
};
it('rejects non-staff users', async () => {
const result = await actions.upsertAddress({
request: createFormRequest(addressFields),
locals: createLocals(mockUser({ roleId: 4 }) as any)
} as any);
expect(result).toEqual({
success: false,
action: 'upsertAddress',
addressError: 'Not authorized'
});
});
it('creates a new address when no id is supplied', async () => {
const created = { id: 'a1', ...addressFields };
mockAddressCreate.mockResolvedValue(created);
const result = await actions.upsertAddress({
request: createFormRequest(addressFields),
locals: staff()
} as any);
expect(result).toEqual({ success: true, action: 'upsertAddress', address: created });
expect(mockAddressCreate).toHaveBeenCalledWith({
data: {
profileId: 'p1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US',
type: 'Mailing',
isDefaultMailing: false,
isDefaultBilling: false,
isDefaultShipping: false
}
});
});
it('updates an existing address when an id is supplied', async () => {
mockAddressFindFirst.mockResolvedValue({ id: 'a1' });
const updated = { id: 'a1', ...addressFields, street: '456 Oak Ave' };
mockAddressUpdate.mockResolvedValue(updated);
const result = await actions.upsertAddress({
request: createFormRequest({ ...addressFields, id: 'a1', street: '456 Oak Ave' }),
locals: staff()
} as any);
expect(result).toEqual({ success: true, action: 'upsertAddress', address: updated });
expect(mockAddressFindFirst).toHaveBeenCalledWith({
where: { id: 'a1', profileId: 'p1' }
});
expect(mockAddressUpdate).toHaveBeenCalledWith({
where: { id: 'a1' },
data: expect.objectContaining({ street: '456 Oak Ave', profileId: 'p1' })
});
expect(mockAddressCreate).not.toHaveBeenCalled();
});
it('errors when updating an address that does not belong to the profile', async () => {
mockAddressFindFirst.mockResolvedValue(null);
const result = await actions.upsertAddress({
request: createFormRequest({ ...addressFields, id: 'a1' }),
locals: staff()
} as any);
expect(result).toEqual({
success: false,
action: 'upsertAddress',
addressError: 'Address not found'
});
expect(mockAddressUpdate).not.toHaveBeenCalled();
});
});
describe('deleteAddress', () => {
it('rejects non-staff users', async () => {
const result = await actions.deleteAddress({
request: createFormRequest({ id: 'a1' }),
locals: createLocals(mockUser({ roleId: 4 }) as any)
} as any);
expect(result).toEqual({
success: false,
action: 'deleteAddress',
addressError: 'Not authorized'
});
expect(mockAddressDelete).not.toHaveBeenCalled();
});
it('deletes the address by id for staff', async () => {
mockAddressDelete.mockResolvedValue({});
const result = await actions.deleteAddress({
request: createFormRequest({ id: 'a1' }),
locals: staff()
} as any);
expect(result).toEqual({ success: true, action: 'deleteAddress', addressId: 'a1' });
expect(mockAddressDelete).toHaveBeenCalledWith({ where: { id: 'a1' } });
});
it('returns an error when id is missing', async () => {
const result = await actions.deleteAddress({
request: createFormRequest({}),
locals: staff()
} as any);
expect(result).toEqual({
success: false,
action: 'deleteAddress',
addressError: 'Missing address id'
});
});
});
});
@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from 'vitest';
import { createLocals, createFormRequest, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
solfeggioFrequency: { create: vi.fn(), update: vi.fn(), delete: vi.fn() },
proclamationCategory: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() },
proclamationGroup: { findFirst: vi.fn(), create: vi.fn(), update: vi.fn(), delete: vi.fn() }
}
}));
import { actions as frequencies } from './solfeggio-frequencies/+page.server';
import { actions as categories } from './proclamation-categories/+page.server';
import { actions as groups } from './proclamation-groups/+page.server';
const staff = () => createLocals(mockUser({ roleId: 3 }) as any);
const user = () => createLocals(mockUser({ roleId: 4 }) as any);
const anonymous = () => createLocals();
type CrudActions = {
upsert: (event: unknown) => Promise<unknown>;
delete: (event: unknown) => Promise<unknown>;
};
const pages: Array<[string, CrudActions]> = [
['solfeggio-frequencies', frequencies as unknown as CrudActions],
['proclamation-categories', categories as unknown as CrudActions],
['proclamation-groups', groups as unknown as CrudActions]
];
describe('admin CRUD action role guards', () => {
for (const [name, actions] of pages) {
it(`${name}: upsert rejects an anonymous request`, async () => {
const result = await actions.upsert({
request: createFormRequest({ name: 'x' }),
locals: anonymous()
});
expect(result).toEqual({ success: false, action: 'upsert', error: 'Not authorized' });
});
it(`${name}: upsert rejects a regular user`, async () => {
const result = await actions.upsert({
request: createFormRequest({ name: 'x' }),
locals: user()
});
expect(result).toEqual({ success: false, action: 'upsert', error: 'Not authorized' });
});
it(`${name}: delete rejects a staff (non-admin) user`, async () => {
const result = await actions.delete({
request: createFormRequest({ id: '1' }),
locals: staff()
});
expect(result).toEqual({ success: false, error: 'Not authorized' });
});
}
});
@@ -1,5 +1,6 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { isAdmin, isStaff } from '$lib/server/roles';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
@@ -16,7 +17,11 @@ export const load: PageServerLoad = async () => {
};
export const actions = {
upsert: async ({ request }) => {
upsert: async ({ request, locals }) => {
if (!isStaff(locals)) {
return { success: false, action: 'upsert', error: 'Not authorized' };
}
const formData = await request.formData();
const form = await superValidate(formData, zod4(SolfeggioSchema));
if (!form.valid) {
@@ -48,7 +53,11 @@ export const actions = {
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
delete: async ({ request, locals }) => {
if (!isAdmin(locals)) {
return { success: false, error: 'Not authorized' };
}
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };

Some files were not shown because too many files have changed in this diff Show More