Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c19c13b192 | ||
|
|
ec826093eb | ||
|
|
96a1342c10 | ||
|
|
468a65a688 | ||
|
|
e934a98f00 | ||
|
|
15bb51582b | ||
|
|
46d7cf66b8 | ||
|
|
177b7bb13a | ||
|
|
750a9286c9 | ||
|
|
c177e7953d | ||
|
|
5f4116b8e8 | ||
|
|
545fb22910 | ||
|
|
4922e941ae | ||
|
|
e6b492a335 | ||
|
|
960673bbc6 | ||
|
|
a909db39b1 | ||
|
|
020289966d | ||
|
|
fa614b8c37 | ||
|
|
cf8fc7b719 | ||
|
|
848e646880 | ||
|
|
98d9b095d9 | ||
|
|
2bbee98594 | ||
|
|
4d929df604 | ||
|
|
10aad07e6d | ||
|
|
d16965dab6 | ||
|
|
7596937f30 | ||
|
|
0cb4b2148a | ||
|
|
9de0e2e25a | ||
|
|
9cd5397395 | ||
|
|
f6b8cad15e | ||
|
|
29d53af8dd | ||
|
|
809ced6d8a | ||
|
|
84c905c9ff | ||
|
|
ae8d661364 | ||
|
|
5ec3018f8b | ||
|
|
8a3c1587b4 | ||
|
|
e60d61e90a | ||
|
|
f1d80279e9 | ||
|
|
4979d955c2 | ||
|
|
0175cab0b4 | ||
|
|
1aecc51bbd | ||
|
|
f50eb34769 | ||
|
|
4a1777de87 | ||
|
|
d89fc6837c |
+4
-1
@@ -5,5 +5,8 @@ GOOGLE_CLIENT_ID=6
|
|||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
RESEND_API_KEY=
|
RESEND_API_KEY=
|
||||||
EMAIL_FROM=
|
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=
|
API_V2_ONBOARDING_SECRET=
|
||||||
@@ -45,10 +45,8 @@ CRUD layer has been removed; see `docs/ADMIN_OVERHAUL.md` for the migration note
|
|||||||
| -------------------------------- | ------------------------ |
|
| -------------------------------- | ------------------------ |
|
||||||
| `/admin/users` | Name, Email, Role |
|
| `/admin/users` | Name, Email, Role |
|
||||||
| `/admin/profiles` | Reference implementation |
|
| `/admin/profiles` | Reference implementation |
|
||||||
| `/admin/user-roles` | |
|
|
||||||
| `/admin/proclamations` | Super-only content edits |
|
| `/admin/proclamations` | Super-only content edits |
|
||||||
| `/admin/solfeggio-frequencies` | |
|
| `/admin/solfeggio-frequencies` | |
|
||||||
| `/admin/proclamation-categories` | Draggable reorder |
|
| `/admin/proclamation-categories` | Draggable reorder |
|
||||||
| `/admin/proclamation-groups` | Draggable reorder |
|
| `/admin/proclamation-groups` | Draggable reorder |
|
||||||
| `/admin/license-keys` | List-only + rotate |
|
| `/admin/license-keys` | List-only + rotate |
|
||||||
| `/admin/api-keys` | Super-only key rotation |
|
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
# GHL Account Creation Webhook
|
||||||
|
|
||||||
Endpoint:
|
Endpoint:
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
|
||||||
@@ -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 />
|
||||||
|
© Radiant Wave Technologies
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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">
|
||||||
|
© Radiant Wave Technologies
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 />
|
||||||
|
© Radiant Wave Technologies
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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 />
|
||||||
|
© Radiant Wave Technologies
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -5,6 +5,15 @@ import { defineConfig } from 'prisma/config';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: 'prisma/schema.prisma',
|
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: {
|
migrations: {
|
||||||
path: 'prisma/migrations',
|
path: 'prisma/migrations',
|
||||||
seed: 'tsx prisma/seed.ts'
|
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;
|
||||||
+83
-1
@@ -21,13 +21,14 @@ model User {
|
|||||||
image String?
|
image String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
roleId Int?
|
roleId Int? @default(4) // New users default to "User"
|
||||||
role UserRole? @relation(fields: [roleId], references: [id])
|
role UserRole? @relation(fields: [roleId], references: [id])
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
profiles Profile[]
|
profiles Profile[]
|
||||||
licenseKeys LicenseKey[]
|
licenseKeys LicenseKey[]
|
||||||
userProclamations UserProclamations[]
|
userProclamations UserProclamations[]
|
||||||
|
schedule Schedule?
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
@@ -130,6 +131,10 @@ model Proclamation {
|
|||||||
groupId Int
|
groupId Int
|
||||||
group ProclamationGroup @relation(fields: [groupId], references: [id])
|
group ProclamationGroup @relation(fields: [groupId], references: [id])
|
||||||
userProclamations UserProclamations[]
|
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 {
|
model ProclamationGroup {
|
||||||
@@ -195,9 +200,14 @@ model LicenseKey {
|
|||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
key String @unique
|
key String @unique
|
||||||
active Boolean
|
active Boolean
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
lastValidatedAt DateTime?
|
||||||
userId String?
|
userId String?
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
email String?
|
email String?
|
||||||
|
// Legacy subscription level. Retained in the database but unmanaged; hidden
|
||||||
|
// from the client and left NULL.
|
||||||
|
level String? @ignore
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@ -221,3 +231,75 @@ model ColorPalette {
|
|||||||
|
|
||||||
@@map("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])
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+3
-3
@@ -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';
|
import type { ApiKeyRecord } from '$lib/server/api-key';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
session: Session | null;
|
session: typeof auth.$Infer.Session.session | null;
|
||||||
user: User | null;
|
user: typeof auth.$Infer.Session.user | null;
|
||||||
apiKey?: ApiKeyRecord;
|
apiKey?: ApiKeyRecord;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,19 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<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%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,24 @@ import { svelteKitHandler } from 'better-auth/svelte-kit';
|
|||||||
import { building } from '$app/environment';
|
import { building } from '$app/environment';
|
||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { validateApiKey } from '$lib/server/api-key';
|
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 }) {
|
export async function handle({ event, resolve }) {
|
||||||
|
return runWithAuditContext(contextFor(event), async () => {
|
||||||
// v2 auth is ALWAYS enforced — no API_KEYS_ACTIVE bypass. The only
|
// v2 auth is ALWAYS enforced — no API_KEYS_ACTIVE bypass. The only
|
||||||
// exceptions are the signed onboarding routes (HMAC-protected).
|
// exceptions are the signed onboarding routes (HMAC-protected).
|
||||||
// TODO: re-enable when v2 API key auth is ready.
|
// TODO: re-enable when v2 API key auth is ready.
|
||||||
@@ -67,7 +83,12 @@ export async function handle({ event, resolve }) {
|
|||||||
if (session) {
|
if (session) {
|
||||||
event.locals.session = session.session;
|
event.locals.session = session.session;
|
||||||
event.locals.user = session.user;
|
event.locals.user = session.user;
|
||||||
|
|
||||||
|
return runWithAuditContext(contextFor(event, session.user), () =>
|
||||||
|
svelteKitHandler({ event, resolve, auth, building })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return svelteKitHandler({ event, resolve, auth, building });
|
return svelteKitHandler({ event, resolve, auth, building });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,40 @@ import { sveltekitCookies } from 'better-auth/svelte-kit';
|
|||||||
import { getRequestEvent } from '$app/server';
|
import { getRequestEvent } from '$app/server';
|
||||||
import { sendMail } from './server/resend';
|
import { sendMail } from './server/resend';
|
||||||
import { sendVerificationEmail } from './server/email-verification';
|
import { sendVerificationEmail } from './server/email-verification';
|
||||||
|
import { sendRegistrationEmail } from './server/registration-email';
|
||||||
import { env } from '$env/dynamic/private';
|
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({
|
export const auth = betterAuth({
|
||||||
baseURL: env.BETTER_AUTH_URL,
|
baseURL: env.BETTER_AUTH_URL,
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
provider: 'postgresql'
|
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: {
|
emailVerification: {
|
||||||
sendOnSignUp: true,
|
sendOnSignUp: true,
|
||||||
sendVerificationEmail: async ({ user, token }) => {
|
sendVerificationEmail: async ({ user, token }) => {
|
||||||
@@ -27,8 +54,16 @@ export const auth = betterAuth({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
requireEmailVerification: true,
|
requireEmailVerification: true,
|
||||||
sendResetPassword: async ({ user, url }) => {
|
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({
|
void sendMail({
|
||||||
to: user.email,
|
to: user.email,
|
||||||
|
subject: 'Reset your Radiant Wave password',
|
||||||
templateId: 'password-reset-email',
|
templateId: 'password-reset-email',
|
||||||
data: {
|
data: {
|
||||||
target_email_address: user.email,
|
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>
|
||||||
@@ -95,14 +95,14 @@
|
|||||||
|
|
||||||
<div class="mx-auto w-full">
|
<div class="mx-auto w-full">
|
||||||
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<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">
|
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
placeholder={searchPlaceholder}
|
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}
|
{#if onNew}
|
||||||
<button
|
<button
|
||||||
@@ -128,11 +128,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm">
|
<div
|
||||||
<table class="w-full min-w-max text-left text-sm text-slate-700">
|
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>
|
<thead>
|
||||||
<tr
|
<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}
|
{#if draggable}
|
||||||
<th scope="col" class="w-10 px-4 py-3"></th>
|
<th scope="col" class="w-10 px-4 py-3"></th>
|
||||||
@@ -145,8 +147,10 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each rows as record, i (rowKey(record, i))}
|
{#each rows as record, i (rowKey(record, i))}
|
||||||
<tr
|
<tr
|
||||||
class="transition {i % 2 === 0 ? 'bg-white' : 'bg-slate-50'} {onRowClick
|
class="transition {i % 2 === 0
|
||||||
? 'cursor-pointer hover:bg-indigo-50'
|
? '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}
|
{draggable}
|
||||||
ondragstart={(e) => handleDragStart(e, i)}
|
ondragstart={(e) => handleDragStart(e, i)}
|
||||||
@@ -158,7 +162,7 @@
|
|||||||
{#if draggable}
|
{#if draggable}
|
||||||
<td class="px-4 py-3" onclick={(e) => e.stopPropagation()} aria-hidden="true">
|
<td class="px-4 py-3" onclick={(e) => e.stopPropagation()} aria-hidden="true">
|
||||||
<span
|
<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
|
title="Drag to reorder">⠿</span
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
@@ -175,7 +179,7 @@
|
|||||||
copyValue(column.value(record));
|
copyValue(column.value(record));
|
||||||
}}
|
}}
|
||||||
title="Copy to clipboard"
|
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
|
<svg
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
@@ -203,7 +207,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colspan={columns.length + (draggable ? 1 : 0)}
|
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.
|
No results found.
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -53,18 +53,18 @@
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
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
|
<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">
|
<div class="flex items-center gap-2">
|
||||||
{#if canDelete && recordId}
|
{#if canDelete && recordId}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={confirmDelete}
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
aria-label="Close"
|
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"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -26,15 +26,15 @@
|
|||||||
{#if visible}
|
{#if visible}
|
||||||
<div
|
<div
|
||||||
role="alert"
|
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>{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={dismiss}
|
onclick={dismiss}
|
||||||
aria-label="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"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="mx-auto w-full max-w-md px-4 py-8 sm:py-12">
|
<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()}
|
{@render children()}
|
||||||
</div>
|
</div>
|
||||||
</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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
+160
-6
@@ -2,18 +2,172 @@ import pg from 'pg';
|
|||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { PrismaClient } from '$lib/generated/prisma/client';
|
import { PrismaClient } from '$lib/generated/prisma/client';
|
||||||
import { env } from '$env/dynamic/private';
|
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 _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 {
|
function getPrisma(): PrismaClient {
|
||||||
if (!_prisma) {
|
if (_prisma) return _prisma;
|
||||||
const pool = new pg.Pool({
|
|
||||||
connectionString: env.DATABASE_URL
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const adapter = new PrismaPg(pool);
|
_prisma = extended as unknown as PrismaClient;
|
||||||
_prisma = new PrismaClient({ adapter });
|
|
||||||
}
|
|
||||||
return _prisma;
|
return _prisma;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -92,3 +92,23 @@ export type ApiKey = Prisma.ApiKeyModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type ColorPalette = Prisma.ColorPaletteModel
|
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
|
||||||
|
|||||||
@@ -116,3 +116,23 @@ export type ApiKey = Prisma.ApiKeyModel
|
|||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
export type ColorPalette = Prisma.ColorPaletteModel
|
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>
|
_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> = {
|
export type NestedStringFilter<$PrismaModel = never> = {
|
||||||
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
|
||||||
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
|
||||||
@@ -478,4 +529,28 @@ export type NestedJsonFilterBase<$PrismaModel = never> = {
|
|||||||
not?: runtime.InputJsonValue | Prisma.JsonFieldRefInput<$PrismaModel> | Prisma.JsonNullValueFilter
|
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',
|
ProclamationCategory: 'ProclamationCategory',
|
||||||
LicenseKey: 'LicenseKey',
|
LicenseKey: 'LicenseKey',
|
||||||
ApiKey: 'ApiKey',
|
ApiKey: 'ApiKey',
|
||||||
ColorPalette: 'ColorPalette'
|
ColorPalette: 'ColorPalette',
|
||||||
|
AuditLog: 'AuditLog',
|
||||||
|
Schedule: 'Schedule',
|
||||||
|
Block: 'Block',
|
||||||
|
BlockProclamations: 'BlockProclamations'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
@@ -414,7 +418,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
|
|||||||
omit: GlobalOmitOptions
|
omit: GlobalOmitOptions
|
||||||
}
|
}
|
||||||
meta: {
|
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
|
txIsolationLevel: TransactionIsolationLevel
|
||||||
}
|
}
|
||||||
model: {
|
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: {
|
other: {
|
||||||
@@ -1717,6 +2017,8 @@ export const LicenseKeyScalarFieldEnum = {
|
|||||||
id: 'id',
|
id: 'id',
|
||||||
key: 'key',
|
key: 'key',
|
||||||
active: 'active',
|
active: 'active',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
lastValidatedAt: 'lastValidatedAt',
|
||||||
userId: 'userId',
|
userId: 'userId',
|
||||||
email: 'email'
|
email: 'email'
|
||||||
} as const
|
} as const
|
||||||
@@ -1744,6 +2046,59 @@ export const ColorPaletteScalarFieldEnum = {
|
|||||||
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof 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 = {
|
export const SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -1759,6 +2114,14 @@ export const JsonNullValueInput = {
|
|||||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof 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 = {
|
export const QueryMode = {
|
||||||
default: 'default',
|
default: 'default',
|
||||||
insensitive: 'insensitive'
|
insensitive: 'insensitive'
|
||||||
@@ -1991,6 +2354,10 @@ export type GlobalOmitConfig = {
|
|||||||
licenseKey?: Prisma.LicenseKeyOmit
|
licenseKey?: Prisma.LicenseKeyOmit
|
||||||
apiKey?: Prisma.ApiKeyOmit
|
apiKey?: Prisma.ApiKeyOmit
|
||||||
colorPalette?: Prisma.ColorPaletteOmit
|
colorPalette?: Prisma.ColorPaletteOmit
|
||||||
|
auditLog?: Prisma.AuditLogOmit
|
||||||
|
schedule?: Prisma.ScheduleOmit
|
||||||
|
block?: Prisma.BlockOmit
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsOmit
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Types for Logging */
|
/* Types for Logging */
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ export const ModelName = {
|
|||||||
ProclamationCategory: 'ProclamationCategory',
|
ProclamationCategory: 'ProclamationCategory',
|
||||||
LicenseKey: 'LicenseKey',
|
LicenseKey: 'LicenseKey',
|
||||||
ApiKey: 'ApiKey',
|
ApiKey: 'ApiKey',
|
||||||
ColorPalette: 'ColorPalette'
|
ColorPalette: 'ColorPalette',
|
||||||
|
AuditLog: 'AuditLog',
|
||||||
|
Schedule: 'Schedule',
|
||||||
|
Block: 'Block',
|
||||||
|
BlockProclamations: 'BlockProclamations'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
|
||||||
@@ -234,6 +238,8 @@ export const LicenseKeyScalarFieldEnum = {
|
|||||||
id: 'id',
|
id: 'id',
|
||||||
key: 'key',
|
key: 'key',
|
||||||
active: 'active',
|
active: 'active',
|
||||||
|
createdAt: 'createdAt',
|
||||||
|
lastValidatedAt: 'lastValidatedAt',
|
||||||
userId: 'userId',
|
userId: 'userId',
|
||||||
email: 'email'
|
email: 'email'
|
||||||
} as const
|
} as const
|
||||||
@@ -261,6 +267,59 @@ export const ColorPaletteScalarFieldEnum = {
|
|||||||
export type ColorPaletteScalarFieldEnum = (typeof ColorPaletteScalarFieldEnum)[keyof typeof 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 = {
|
export const SortOrder = {
|
||||||
asc: 'asc',
|
asc: 'asc',
|
||||||
desc: 'desc'
|
desc: 'desc'
|
||||||
@@ -276,6 +335,14 @@ export const JsonNullValueInput = {
|
|||||||
export type JsonNullValueInput = (typeof JsonNullValueInput)[keyof typeof 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 = {
|
export const QueryMode = {
|
||||||
default: 'default',
|
default: 'default',
|
||||||
insensitive: 'insensitive'
|
insensitive: 'insensitive'
|
||||||
|
|||||||
@@ -23,4 +23,8 @@ export type * from './models/ProclamationCategory.ts'
|
|||||||
export type * from './models/LicenseKey.ts'
|
export type * from './models/LicenseKey.ts'
|
||||||
export type * from './models/ApiKey.ts'
|
export type * from './models/ApiKey.ts'
|
||||||
export type * from './models/ColorPalette.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'
|
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
@@ -28,6 +28,8 @@ export type LicenseKeyMinAggregateOutputType = {
|
|||||||
id: string | null
|
id: string | null
|
||||||
key: string | null
|
key: string | null
|
||||||
active: boolean | null
|
active: boolean | null
|
||||||
|
createdAt: Date | null
|
||||||
|
lastValidatedAt: Date | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
}
|
}
|
||||||
@@ -36,6 +38,8 @@ export type LicenseKeyMaxAggregateOutputType = {
|
|||||||
id: string | null
|
id: string | null
|
||||||
key: string | null
|
key: string | null
|
||||||
active: boolean | null
|
active: boolean | null
|
||||||
|
createdAt: Date | null
|
||||||
|
lastValidatedAt: Date | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
}
|
}
|
||||||
@@ -44,6 +48,8 @@ export type LicenseKeyCountAggregateOutputType = {
|
|||||||
id: number
|
id: number
|
||||||
key: number
|
key: number
|
||||||
active: number
|
active: number
|
||||||
|
createdAt: number
|
||||||
|
lastValidatedAt: number
|
||||||
userId: number
|
userId: number
|
||||||
email: number
|
email: number
|
||||||
_all: number
|
_all: number
|
||||||
@@ -54,6 +60,8 @@ export type LicenseKeyMinAggregateInputType = {
|
|||||||
id?: true
|
id?: true
|
||||||
key?: true
|
key?: true
|
||||||
active?: true
|
active?: true
|
||||||
|
createdAt?: true
|
||||||
|
lastValidatedAt?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
email?: true
|
email?: true
|
||||||
}
|
}
|
||||||
@@ -62,6 +70,8 @@ export type LicenseKeyMaxAggregateInputType = {
|
|||||||
id?: true
|
id?: true
|
||||||
key?: true
|
key?: true
|
||||||
active?: true
|
active?: true
|
||||||
|
createdAt?: true
|
||||||
|
lastValidatedAt?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
email?: true
|
email?: true
|
||||||
}
|
}
|
||||||
@@ -70,6 +80,8 @@ export type LicenseKeyCountAggregateInputType = {
|
|||||||
id?: true
|
id?: true
|
||||||
key?: true
|
key?: true
|
||||||
active?: true
|
active?: true
|
||||||
|
createdAt?: true
|
||||||
|
lastValidatedAt?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
email?: true
|
email?: true
|
||||||
_all?: true
|
_all?: true
|
||||||
@@ -151,6 +163,8 @@ export type LicenseKeyGroupByOutputType = {
|
|||||||
id: string
|
id: string
|
||||||
key: string
|
key: string
|
||||||
active: boolean
|
active: boolean
|
||||||
|
createdAt: Date
|
||||||
|
lastValidatedAt: Date | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
_count: LicenseKeyCountAggregateOutputType | null
|
_count: LicenseKeyCountAggregateOutputType | null
|
||||||
@@ -180,6 +194,8 @@ export type LicenseKeyWhereInput = {
|
|||||||
id?: Prisma.StringFilter<"LicenseKey"> | string
|
id?: Prisma.StringFilter<"LicenseKey"> | string
|
||||||
key?: Prisma.StringFilter<"LicenseKey"> | string
|
key?: Prisma.StringFilter<"LicenseKey"> | string
|
||||||
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
|
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFilter<"LicenseKey"> | Date | string
|
||||||
|
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
|
||||||
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
|
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
|
||||||
@@ -189,6 +205,8 @@ export type LicenseKeyOrderByWithRelationInput = {
|
|||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
key?: Prisma.SortOrder
|
key?: Prisma.SortOrder
|
||||||
active?: Prisma.SortOrder
|
active?: Prisma.SortOrder
|
||||||
|
createdAt?: Prisma.SortOrder
|
||||||
|
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrderInput | Prisma.SortOrder
|
userId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
email?: Prisma.SortOrderInput | Prisma.SortOrder
|
email?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
user?: Prisma.UserOrderByWithRelationInput
|
user?: Prisma.UserOrderByWithRelationInput
|
||||||
@@ -201,6 +219,8 @@ export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
OR?: Prisma.LicenseKeyWhereInput[]
|
OR?: Prisma.LicenseKeyWhereInput[]
|
||||||
NOT?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
|
NOT?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
|
||||||
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
|
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFilter<"LicenseKey"> | Date | string
|
||||||
|
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
|
||||||
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
|
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
|
||||||
@@ -210,6 +230,8 @@ export type LicenseKeyOrderByWithAggregationInput = {
|
|||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
key?: Prisma.SortOrder
|
key?: Prisma.SortOrder
|
||||||
active?: Prisma.SortOrder
|
active?: Prisma.SortOrder
|
||||||
|
createdAt?: Prisma.SortOrder
|
||||||
|
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrderInput | Prisma.SortOrder
|
userId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
email?: Prisma.SortOrderInput | Prisma.SortOrder
|
email?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
_count?: Prisma.LicenseKeyCountOrderByAggregateInput
|
_count?: Prisma.LicenseKeyCountOrderByAggregateInput
|
||||||
@@ -224,6 +246,8 @@ export type LicenseKeyScalarWhereWithAggregatesInput = {
|
|||||||
id?: Prisma.StringWithAggregatesFilter<"LicenseKey"> | string
|
id?: Prisma.StringWithAggregatesFilter<"LicenseKey"> | string
|
||||||
key?: Prisma.StringWithAggregatesFilter<"LicenseKey"> | string
|
key?: Prisma.StringWithAggregatesFilter<"LicenseKey"> | string
|
||||||
active?: Prisma.BoolWithAggregatesFilter<"LicenseKey"> | boolean
|
active?: Prisma.BoolWithAggregatesFilter<"LicenseKey"> | boolean
|
||||||
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"LicenseKey"> | Date | string
|
||||||
|
lastValidatedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"LicenseKey"> | Date | string | null
|
||||||
userId?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
|
userId?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
|
||||||
email?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
|
email?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
|
||||||
}
|
}
|
||||||
@@ -232,6 +256,8 @@ export type LicenseKeyCreateInput = {
|
|||||||
id?: string
|
id?: string
|
||||||
key: string
|
key: string
|
||||||
active: boolean
|
active: boolean
|
||||||
|
createdAt?: Date | string
|
||||||
|
lastValidatedAt?: Date | string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
|
user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
|
||||||
}
|
}
|
||||||
@@ -240,6 +266,8 @@ export type LicenseKeyUncheckedCreateInput = {
|
|||||||
id?: string
|
id?: string
|
||||||
key: string
|
key: string
|
||||||
active: boolean
|
active: boolean
|
||||||
|
createdAt?: Date | string
|
||||||
|
lastValidatedAt?: Date | string | null
|
||||||
userId?: string | null
|
userId?: string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
}
|
}
|
||||||
@@ -248,6 +276,8 @@ export type LicenseKeyUpdateInput = {
|
|||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
key?: Prisma.StringFieldUpdateOperationsInput | string
|
key?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
user?: Prisma.UserUpdateOneWithoutLicenseKeysNestedInput
|
user?: Prisma.UserUpdateOneWithoutLicenseKeysNestedInput
|
||||||
}
|
}
|
||||||
@@ -256,6 +286,8 @@ export type LicenseKeyUncheckedUpdateInput = {
|
|||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
key?: Prisma.StringFieldUpdateOperationsInput | string
|
key?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
}
|
}
|
||||||
@@ -264,6 +296,8 @@ export type LicenseKeyCreateManyInput = {
|
|||||||
id?: string
|
id?: string
|
||||||
key: string
|
key: string
|
||||||
active: boolean
|
active: boolean
|
||||||
|
createdAt?: Date | string
|
||||||
|
lastValidatedAt?: Date | string | null
|
||||||
userId?: string | null
|
userId?: string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
}
|
}
|
||||||
@@ -272,6 +306,8 @@ export type LicenseKeyUpdateManyMutationInput = {
|
|||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
key?: Prisma.StringFieldUpdateOperationsInput | string
|
key?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +315,8 @@ export type LicenseKeyUncheckedUpdateManyInput = {
|
|||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
key?: Prisma.StringFieldUpdateOperationsInput | string
|
key?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
}
|
}
|
||||||
@@ -297,6 +335,8 @@ export type LicenseKeyCountOrderByAggregateInput = {
|
|||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
key?: Prisma.SortOrder
|
key?: Prisma.SortOrder
|
||||||
active?: Prisma.SortOrder
|
active?: Prisma.SortOrder
|
||||||
|
createdAt?: Prisma.SortOrder
|
||||||
|
lastValidatedAt?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
@@ -305,6 +345,8 @@ export type LicenseKeyMaxOrderByAggregateInput = {
|
|||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
key?: Prisma.SortOrder
|
key?: Prisma.SortOrder
|
||||||
active?: Prisma.SortOrder
|
active?: Prisma.SortOrder
|
||||||
|
createdAt?: Prisma.SortOrder
|
||||||
|
lastValidatedAt?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
@@ -313,6 +355,8 @@ export type LicenseKeyMinOrderByAggregateInput = {
|
|||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
key?: Prisma.SortOrder
|
key?: Prisma.SortOrder
|
||||||
active?: Prisma.SortOrder
|
active?: Prisma.SortOrder
|
||||||
|
createdAt?: Prisma.SortOrder
|
||||||
|
lastValidatedAt?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
@@ -363,6 +407,8 @@ export type LicenseKeyCreateWithoutUserInput = {
|
|||||||
id?: string
|
id?: string
|
||||||
key: string
|
key: string
|
||||||
active: boolean
|
active: boolean
|
||||||
|
createdAt?: Date | string
|
||||||
|
lastValidatedAt?: Date | string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +416,8 @@ export type LicenseKeyUncheckedCreateWithoutUserInput = {
|
|||||||
id?: string
|
id?: string
|
||||||
key: string
|
key: string
|
||||||
active: boolean
|
active: boolean
|
||||||
|
createdAt?: Date | string
|
||||||
|
lastValidatedAt?: Date | string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +454,8 @@ export type LicenseKeyScalarWhereInput = {
|
|||||||
id?: Prisma.StringFilter<"LicenseKey"> | string
|
id?: Prisma.StringFilter<"LicenseKey"> | string
|
||||||
key?: Prisma.StringFilter<"LicenseKey"> | string
|
key?: Prisma.StringFilter<"LicenseKey"> | string
|
||||||
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
|
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFilter<"LicenseKey"> | Date | string
|
||||||
|
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
|
||||||
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
}
|
}
|
||||||
@@ -414,6 +464,8 @@ export type LicenseKeyCreateManyUserInput = {
|
|||||||
id?: string
|
id?: string
|
||||||
key: string
|
key: string
|
||||||
active: boolean
|
active: boolean
|
||||||
|
createdAt?: Date | string
|
||||||
|
lastValidatedAt?: Date | string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -421,6 +473,8 @@ export type LicenseKeyUpdateWithoutUserInput = {
|
|||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
key?: Prisma.StringFieldUpdateOperationsInput | string
|
key?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,6 +482,8 @@ export type LicenseKeyUncheckedUpdateWithoutUserInput = {
|
|||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
key?: Prisma.StringFieldUpdateOperationsInput | string
|
key?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -435,6 +491,8 @@ export type LicenseKeyUncheckedUpdateManyWithoutUserInput = {
|
|||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
key?: Prisma.StringFieldUpdateOperationsInput | string
|
key?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,6 +502,8 @@ export type LicenseKeySelect<ExtArgs extends runtime.Types.Extensions.InternalAr
|
|||||||
id?: boolean
|
id?: boolean
|
||||||
key?: boolean
|
key?: boolean
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
createdAt?: boolean
|
||||||
|
lastValidatedAt?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
email?: boolean
|
email?: boolean
|
||||||
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
||||||
@@ -453,6 +513,8 @@ export type LicenseKeySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Ex
|
|||||||
id?: boolean
|
id?: boolean
|
||||||
key?: boolean
|
key?: boolean
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
createdAt?: boolean
|
||||||
|
lastValidatedAt?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
email?: boolean
|
email?: boolean
|
||||||
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
||||||
@@ -462,6 +524,8 @@ export type LicenseKeySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Ex
|
|||||||
id?: boolean
|
id?: boolean
|
||||||
key?: boolean
|
key?: boolean
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
createdAt?: boolean
|
||||||
|
lastValidatedAt?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
email?: boolean
|
email?: boolean
|
||||||
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
||||||
@@ -471,11 +535,13 @@ export type LicenseKeySelectScalar = {
|
|||||||
id?: boolean
|
id?: boolean
|
||||||
key?: boolean
|
key?: boolean
|
||||||
active?: boolean
|
active?: boolean
|
||||||
|
createdAt?: boolean
|
||||||
|
lastValidatedAt?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
email?: 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> = {
|
export type LicenseKeyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
@@ -495,6 +561,8 @@ export type $LicenseKeyPayload<ExtArgs extends runtime.Types.Extensions.Internal
|
|||||||
id: string
|
id: string
|
||||||
key: string
|
key: string
|
||||||
active: boolean
|
active: boolean
|
||||||
|
createdAt: Date
|
||||||
|
lastValidatedAt: Date | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
}, ExtArgs["result"]["licenseKey"]>
|
}, ExtArgs["result"]["licenseKey"]>
|
||||||
@@ -924,6 +992,8 @@ export interface LicenseKeyFieldRefs {
|
|||||||
readonly id: Prisma.FieldRef<"LicenseKey", 'String'>
|
readonly id: Prisma.FieldRef<"LicenseKey", 'String'>
|
||||||
readonly key: Prisma.FieldRef<"LicenseKey", 'String'>
|
readonly key: Prisma.FieldRef<"LicenseKey", 'String'>
|
||||||
readonly active: Prisma.FieldRef<"LicenseKey", 'Boolean'>
|
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 userId: Prisma.FieldRef<"LicenseKey", 'String'>
|
||||||
readonly email: Prisma.FieldRef<"LicenseKey", 'String'>
|
readonly email: Prisma.FieldRef<"LicenseKey", 'String'>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ export type ProclamationWhereInput = {
|
|||||||
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
|
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
|
||||||
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
|
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
|
||||||
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsListRelationFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationOrderByWithRelationInput = {
|
export type ProclamationOrderByWithRelationInput = {
|
||||||
@@ -249,6 +250,7 @@ export type ProclamationOrderByWithRelationInput = {
|
|||||||
category?: Prisma.ProclamationCategoryOrderByWithRelationInput
|
category?: Prisma.ProclamationCategoryOrderByWithRelationInput
|
||||||
group?: Prisma.ProclamationGroupOrderByWithRelationInput
|
group?: Prisma.ProclamationGroupOrderByWithRelationInput
|
||||||
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
|
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsOrderByRelationAggregateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
|
export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
|
||||||
@@ -265,6 +267,7 @@ export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
|
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
|
||||||
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
|
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
|
||||||
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsListRelationFilter
|
||||||
}, "id" | "name">
|
}, "id" | "name">
|
||||||
|
|
||||||
export type ProclamationOrderByWithAggregationInput = {
|
export type ProclamationOrderByWithAggregationInput = {
|
||||||
@@ -301,6 +304,7 @@ export type ProclamationCreateInput = {
|
|||||||
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
|
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
|
||||||
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
|
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedCreateInput = {
|
export type ProclamationUncheckedCreateInput = {
|
||||||
@@ -311,6 +315,7 @@ export type ProclamationUncheckedCreateInput = {
|
|||||||
categoryId: number
|
categoryId: number
|
||||||
groupId: number
|
groupId: number
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUpdateInput = {
|
export type ProclamationUpdateInput = {
|
||||||
@@ -321,6 +326,7 @@ export type ProclamationUpdateInput = {
|
|||||||
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
|
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
|
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedUpdateInput = {
|
export type ProclamationUncheckedUpdateInput = {
|
||||||
@@ -331,6 +337,7 @@ export type ProclamationUncheckedUpdateInput = {
|
|||||||
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
|
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
groupId?: Prisma.IntFieldUpdateOperationsInput | number
|
groupId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationCreateManyInput = {
|
export type ProclamationCreateManyInput = {
|
||||||
@@ -551,6 +558,20 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryNestedInput = {
|
|||||||
deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
|
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 = {
|
export type ProclamationCreateWithoutGroupInput = {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
@@ -558,6 +579,7 @@ export type ProclamationCreateWithoutGroupInput = {
|
|||||||
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
|
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
|
||||||
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
|
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedCreateWithoutGroupInput = {
|
export type ProclamationUncheckedCreateWithoutGroupInput = {
|
||||||
@@ -567,6 +589,7 @@ export type ProclamationUncheckedCreateWithoutGroupInput = {
|
|||||||
solfeggioFrequencyId: number
|
solfeggioFrequencyId: number
|
||||||
categoryId: number
|
categoryId: number
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationCreateOrConnectWithoutGroupInput = {
|
export type ProclamationCreateOrConnectWithoutGroupInput = {
|
||||||
@@ -614,6 +637,7 @@ export type ProclamationCreateWithoutUserProclamationsInput = {
|
|||||||
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
|
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
|
||||||
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
|
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
|
||||||
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
|
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
|
export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
|
||||||
@@ -623,6 +647,7 @@ export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
|
|||||||
solfeggioFrequencyId: number
|
solfeggioFrequencyId: number
|
||||||
categoryId: number
|
categoryId: number
|
||||||
groupId: number
|
groupId: number
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationCreateOrConnectWithoutUserProclamationsInput = {
|
export type ProclamationCreateOrConnectWithoutUserProclamationsInput = {
|
||||||
@@ -648,6 +673,7 @@ export type ProclamationUpdateWithoutUserProclamationsInput = {
|
|||||||
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
|
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
|
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
|
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
|
export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
|
||||||
@@ -657,6 +683,7 @@ export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
|
|||||||
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
|
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
|
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
groupId?: Prisma.IntFieldUpdateOperationsInput | number
|
groupId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
|
export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
|
||||||
@@ -666,6 +693,7 @@ export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
|
|||||||
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
|
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
|
||||||
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
|
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
|
export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
|
||||||
@@ -675,6 +703,7 @@ export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
|
|||||||
categoryId: number
|
categoryId: number
|
||||||
groupId: number
|
groupId: number
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationCreateOrConnectWithoutSolfeggioFrequencyInput = {
|
export type ProclamationCreateOrConnectWithoutSolfeggioFrequencyInput = {
|
||||||
@@ -710,6 +739,7 @@ export type ProclamationCreateWithoutCategoryInput = {
|
|||||||
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
|
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
|
||||||
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
|
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedCreateWithoutCategoryInput = {
|
export type ProclamationUncheckedCreateWithoutCategoryInput = {
|
||||||
@@ -719,6 +749,7 @@ export type ProclamationUncheckedCreateWithoutCategoryInput = {
|
|||||||
solfeggioFrequencyId: number
|
solfeggioFrequencyId: number
|
||||||
groupId: number
|
groupId: number
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationCreateOrConnectWithoutCategoryInput = {
|
export type ProclamationCreateOrConnectWithoutCategoryInput = {
|
||||||
@@ -747,6 +778,62 @@ export type ProclamationUpdateManyWithWhereWithoutCategoryInput = {
|
|||||||
data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutCategoryInput>
|
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 = {
|
export type ProclamationCreateManyGroupInput = {
|
||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
@@ -762,6 +849,7 @@ export type ProclamationUpdateWithoutGroupInput = {
|
|||||||
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
|
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
|
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedUpdateWithoutGroupInput = {
|
export type ProclamationUncheckedUpdateWithoutGroupInput = {
|
||||||
@@ -771,6 +859,7 @@ export type ProclamationUncheckedUpdateWithoutGroupInput = {
|
|||||||
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
|
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
|
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedUpdateManyWithoutGroupInput = {
|
export type ProclamationUncheckedUpdateManyWithoutGroupInput = {
|
||||||
@@ -796,6 +885,7 @@ export type ProclamationUpdateWithoutSolfeggioFrequencyInput = {
|
|||||||
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
|
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
|
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
|
export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
|
||||||
@@ -805,6 +895,7 @@ export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
|
|||||||
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
|
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
groupId?: Prisma.IntFieldUpdateOperationsInput | number
|
groupId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyInput = {
|
export type ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyInput = {
|
||||||
@@ -830,6 +921,7 @@ export type ProclamationUpdateWithoutCategoryInput = {
|
|||||||
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
|
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
|
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedUpdateWithoutCategoryInput = {
|
export type ProclamationUncheckedUpdateWithoutCategoryInput = {
|
||||||
@@ -839,6 +931,7 @@ export type ProclamationUncheckedUpdateWithoutCategoryInput = {
|
|||||||
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
|
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
groupId?: Prisma.IntFieldUpdateOperationsInput | number
|
groupId?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
||||||
|
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
|
export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
|
||||||
@@ -856,10 +949,12 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
|
|||||||
|
|
||||||
export type ProclamationCountOutputType = {
|
export type ProclamationCountOutputType = {
|
||||||
userProclamations: number
|
userProclamations: number
|
||||||
|
blockProclamations: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProclamationCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type ProclamationCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
userProclamations?: boolean | ProclamationCountOutputTypeCountUserProclamationsArgs
|
userProclamations?: boolean | ProclamationCountOutputTypeCountUserProclamationsArgs
|
||||||
|
blockProclamations?: boolean | ProclamationCountOutputTypeCountBlockProclamationsArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -879,6 +974,13 @@ export type ProclamationCountOutputTypeCountUserProclamationsArgs<ExtArgs extend
|
|||||||
where?: Prisma.UserProclamationsWhereInput
|
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<{
|
export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
@@ -891,6 +993,7 @@ export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.Internal
|
|||||||
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
|
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
|
||||||
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
|
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
|
||||||
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
|
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
|
||||||
|
blockProclamations?: boolean | Prisma.Proclamation$blockProclamationsArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["proclamation"]>
|
}, ExtArgs["result"]["proclamation"]>
|
||||||
|
|
||||||
@@ -933,6 +1036,7 @@ export type ProclamationInclude<ExtArgs extends runtime.Types.Extensions.Interna
|
|||||||
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
|
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
|
||||||
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
|
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
|
||||||
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
|
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
|
||||||
|
blockProclamations?: boolean | Prisma.Proclamation$blockProclamationsArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
export type ProclamationIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
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>
|
category: Prisma.$ProclamationCategoryPayload<ExtArgs>
|
||||||
group: Prisma.$ProclamationGroupPayload<ExtArgs>
|
group: Prisma.$ProclamationGroupPayload<ExtArgs>
|
||||||
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
|
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
|
||||||
|
blockProclamations: Prisma.$BlockProclamationsPayload<ExtArgs>[]
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
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>
|
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>
|
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>
|
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.
|
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
* @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[]
|
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
|
* Proclamation without action
|
||||||
*/
|
*/
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -246,6 +246,7 @@ export type UserWhereInput = {
|
|||||||
profiles?: Prisma.ProfileListRelationFilter
|
profiles?: Prisma.ProfileListRelationFilter
|
||||||
licenseKeys?: Prisma.LicenseKeyListRelationFilter
|
licenseKeys?: Prisma.LicenseKeyListRelationFilter
|
||||||
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
||||||
|
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserOrderByWithRelationInput = {
|
export type UserOrderByWithRelationInput = {
|
||||||
@@ -263,6 +264,7 @@ export type UserOrderByWithRelationInput = {
|
|||||||
profiles?: Prisma.ProfileOrderByRelationAggregateInput
|
profiles?: Prisma.ProfileOrderByRelationAggregateInput
|
||||||
licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput
|
licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput
|
||||||
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
|
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
|
||||||
|
schedule?: Prisma.ScheduleOrderByWithRelationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||||
@@ -283,6 +285,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
profiles?: Prisma.ProfileListRelationFilter
|
profiles?: Prisma.ProfileListRelationFilter
|
||||||
licenseKeys?: Prisma.LicenseKeyListRelationFilter
|
licenseKeys?: Prisma.LicenseKeyListRelationFilter
|
||||||
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
||||||
|
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
|
||||||
}, "id" | "email">
|
}, "id" | "email">
|
||||||
|
|
||||||
export type UserOrderByWithAggregationInput = {
|
export type UserOrderByWithAggregationInput = {
|
||||||
@@ -329,6 +332,7 @@ export type UserCreateInput = {
|
|||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateInput = {
|
export type UserUncheckedCreateInput = {
|
||||||
@@ -345,6 +349,7 @@ export type UserUncheckedCreateInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUpdateInput = {
|
export type UserUpdateInput = {
|
||||||
@@ -361,6 +366,7 @@ export type UserUpdateInput = {
|
|||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateInput = {
|
export type UserUncheckedUpdateInput = {
|
||||||
@@ -377,6 +383,7 @@ export type UserUncheckedUpdateInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateManyInput = {
|
export type UserCreateManyInput = {
|
||||||
@@ -612,6 +619,20 @@ export type UserUpdateOneWithoutLicenseKeysNestedInput = {
|
|||||||
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput>
|
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 = {
|
export type UserCreateWithoutProfilesInput = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -625,6 +646,7 @@ export type UserCreateWithoutProfilesInput = {
|
|||||||
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutProfilesInput = {
|
export type UserUncheckedCreateWithoutProfilesInput = {
|
||||||
@@ -640,6 +662,7 @@ export type UserUncheckedCreateWithoutProfilesInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutProfilesInput = {
|
export type UserCreateOrConnectWithoutProfilesInput = {
|
||||||
@@ -671,6 +694,7 @@ export type UserUpdateWithoutProfilesInput = {
|
|||||||
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutProfilesInput = {
|
export type UserUncheckedUpdateWithoutProfilesInput = {
|
||||||
@@ -686,6 +710,7 @@ export type UserUncheckedUpdateWithoutProfilesInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutSessionsInput = {
|
export type UserCreateWithoutSessionsInput = {
|
||||||
@@ -701,6 +726,7 @@ export type UserCreateWithoutSessionsInput = {
|
|||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutSessionsInput = {
|
export type UserUncheckedCreateWithoutSessionsInput = {
|
||||||
@@ -716,6 +742,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutSessionsInput = {
|
export type UserCreateOrConnectWithoutSessionsInput = {
|
||||||
@@ -747,6 +774,7 @@ export type UserUpdateWithoutSessionsInput = {
|
|||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutSessionsInput = {
|
export type UserUncheckedUpdateWithoutSessionsInput = {
|
||||||
@@ -762,6 +790,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutAccountsInput = {
|
export type UserCreateWithoutAccountsInput = {
|
||||||
@@ -777,6 +806,7 @@ export type UserCreateWithoutAccountsInput = {
|
|||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutAccountsInput = {
|
export type UserUncheckedCreateWithoutAccountsInput = {
|
||||||
@@ -792,6 +822,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutAccountsInput = {
|
export type UserCreateOrConnectWithoutAccountsInput = {
|
||||||
@@ -823,6 +854,7 @@ export type UserUpdateWithoutAccountsInput = {
|
|||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutAccountsInput = {
|
export type UserUncheckedUpdateWithoutAccountsInput = {
|
||||||
@@ -838,6 +870,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutRoleInput = {
|
export type UserCreateWithoutRoleInput = {
|
||||||
@@ -853,6 +886,7 @@ export type UserCreateWithoutRoleInput = {
|
|||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutRoleInput = {
|
export type UserUncheckedCreateWithoutRoleInput = {
|
||||||
@@ -868,6 +902,7 @@ export type UserUncheckedCreateWithoutRoleInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutRoleInput = {
|
export type UserCreateOrConnectWithoutRoleInput = {
|
||||||
@@ -923,6 +958,7 @@ export type UserCreateWithoutUserProclamationsInput = {
|
|||||||
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutUserProclamationsInput = {
|
export type UserUncheckedCreateWithoutUserProclamationsInput = {
|
||||||
@@ -938,6 +974,7 @@ export type UserUncheckedCreateWithoutUserProclamationsInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutUserProclamationsInput = {
|
export type UserCreateOrConnectWithoutUserProclamationsInput = {
|
||||||
@@ -969,6 +1006,7 @@ export type UserUpdateWithoutUserProclamationsInput = {
|
|||||||
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutUserProclamationsInput = {
|
export type UserUncheckedUpdateWithoutUserProclamationsInput = {
|
||||||
@@ -984,6 +1022,7 @@ export type UserUncheckedUpdateWithoutUserProclamationsInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutLicenseKeysInput = {
|
export type UserCreateWithoutLicenseKeysInput = {
|
||||||
@@ -999,6 +1038,7 @@ export type UserCreateWithoutLicenseKeysInput = {
|
|||||||
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutLicenseKeysInput = {
|
export type UserUncheckedCreateWithoutLicenseKeysInput = {
|
||||||
@@ -1014,6 +1054,7 @@ export type UserUncheckedCreateWithoutLicenseKeysInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutLicenseKeysInput = {
|
export type UserCreateOrConnectWithoutLicenseKeysInput = {
|
||||||
@@ -1045,6 +1086,7 @@ export type UserUpdateWithoutLicenseKeysInput = {
|
|||||||
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutLicenseKeysInput = {
|
export type UserUncheckedUpdateWithoutLicenseKeysInput = {
|
||||||
@@ -1060,6 +1102,87 @@ export type UserUncheckedUpdateWithoutLicenseKeysInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
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 = {
|
export type UserCreateManyRoleInput = {
|
||||||
@@ -1085,6 +1208,7 @@ export type UserUpdateWithoutRoleInput = {
|
|||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutRoleInput = {
|
export type UserUncheckedUpdateWithoutRoleInput = {
|
||||||
@@ -1100,6 +1224,7 @@ export type UserUncheckedUpdateWithoutRoleInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateManyWithoutRoleInput = {
|
export type UserUncheckedUpdateManyWithoutRoleInput = {
|
||||||
@@ -1194,6 +1319,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
|||||||
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
|
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
|
||||||
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
|
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
|
||||||
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
|
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
|
||||||
|
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["user"]>
|
}, ExtArgs["result"]["user"]>
|
||||||
|
|
||||||
@@ -1240,6 +1366,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||||||
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
|
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
|
||||||
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
|
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
|
||||||
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
|
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
|
||||||
|
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
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>[]
|
profiles: Prisma.$ProfilePayload<ExtArgs>[]
|
||||||
licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[]
|
licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[]
|
||||||
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
|
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
|
||||||
|
schedule: Prisma.$SchedulePayload<ExtArgs> | null
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
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>
|
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>
|
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>
|
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.
|
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
* @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[]
|
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
|
* User without action
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ function getResend(): Resend {
|
|||||||
* @example
|
* @example
|
||||||
* await sendMail({
|
* await sendMail({
|
||||||
* to: 'jane@example.com',
|
* 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'
|
* subject: 'Your license key is ready', // optional — defaults to 'Template Email'
|
||||||
* data: { // optional — template variables
|
* data: { // optional — template variables
|
||||||
* firstName: 'Jane',
|
* firstName: 'Jane',
|
||||||
@@ -40,7 +40,7 @@ function getResend(): Resend {
|
|||||||
export async function sendMail(options: {
|
export async function sendMail(options: {
|
||||||
to: string;
|
to: string;
|
||||||
subject?: string;
|
subject?: string;
|
||||||
templateId?: string;
|
templateId: string;
|
||||||
data?: Record<string, string | number>;
|
data?: Record<string, string | number>;
|
||||||
}) {
|
}) {
|
||||||
const { to, subject, templateId, data } = options;
|
const { to, subject, templateId, data } = options;
|
||||||
@@ -50,7 +50,7 @@ export async function sendMail(options: {
|
|||||||
to: [to],
|
to: [to],
|
||||||
subject: subject ?? 'Template Email',
|
subject: subject ?? 'Template Email',
|
||||||
template: {
|
template: {
|
||||||
id: templateId ?? env.RESEND_TEMPLATE_ID,
|
id: templateId,
|
||||||
variables: data ?? {}
|
variables: data ?? {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils';
|
||||||
|
|
||||||
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
|
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
|
||||||
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
|
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
|
||||||
// canonical ids (1-9), never the portal DB autoincrement ids, because
|
// canonical ids (1-9), never the portal DB autoincrement ids, because
|
||||||
@@ -138,3 +140,46 @@ export function serializeColorPalette(palette: {
|
|||||||
}): V2ColorPalette {
|
}): V2ColorPalette {
|
||||||
return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors };
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ export function mockLicenseKey(overrides?: Record<string, unknown>) {
|
|||||||
id: 'key-1',
|
id: 'key-1',
|
||||||
key: 'AAAA-BBBB-CCCC-DDDD',
|
key: 'AAAA-BBBB-CCCC-DDDD',
|
||||||
active: true,
|
active: true,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
lastValidatedAt: null,
|
||||||
level: 'standard',
|
level: 'standard',
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
...overrides
|
...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');
|
throw error(403, 'not authorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = await prisma.profile.findFirst({
|
const licenseKeys = await prisma.licenseKey.findMany({
|
||||||
where: { userId: locals.user.id },
|
where: { userId: locals.user.id },
|
||||||
select: { firstName: true }
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const keyStats = await prisma.licenseKey.groupBy({
|
return { licenseKeys };
|
||||||
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
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,33 +1,127 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { resolve } from '$app/paths';
|
import DashboardSection from '$lib/components/DashboardSection.svelte';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
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>
|
</script>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
|
<div>
|
||||||
Welcome, {data.firstName}!
|
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-100">
|
||||||
|
Dashboard
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500 sm:text-base">Here's an overview of your account.</p>
|
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
|
||||||
|
Manage your account and license keys.
|
||||||
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
</p>
|
||||||
<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>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { isHttpError } from '@sveltejs/kit';
|
import { isHttpError } from '@sveltejs/kit';
|
||||||
import { createLocals, mockUser } from '$lib/test-utils';
|
import { createLocals, mockUser, mockLicenseKey } from '$lib/test-utils';
|
||||||
|
|
||||||
vi.mock('$lib/db', () => ({
|
vi.mock('$lib/db', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
profile: { findFirst: vi.fn() },
|
licenseKey: { findMany: vi.fn() }
|
||||||
licenseKey: { groupBy: vi.fn() }
|
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
import { load } from './+page.server';
|
import { load } from './+page.server';
|
||||||
|
|
||||||
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
|
const licenseKeyFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
|
||||||
const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
describe('(protected)/dashboard page server load', () => {
|
describe('(protected)/dashboard page server load', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
profileFindFirst.mockReset();
|
licenseKeyFindMany.mockReset();
|
||||||
licenseKeyGroupBy.mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws 403 when locals.user is undefined', async () => {
|
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);
|
expect(isHttpError(err, 403)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns firstName from profile', async () => {
|
it('returns license keys for the authenticated user', async () => {
|
||||||
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
|
const keys = [
|
||||||
licenseKeyGroupBy.mockResolvedValue([]);
|
mockLicenseKey(),
|
||||||
|
mockLicenseKey({ id: 'key-2', key: 'EEEE-FFFF-GGGG-HHHH', active: false })
|
||||||
|
];
|
||||||
|
licenseKeyFindMany.mockResolvedValue(keys);
|
||||||
|
|
||||||
const locals = createLocals(mockUser() as any);
|
const locals = createLocals(mockUser() as any);
|
||||||
const result = (await load({ locals } as any)) 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 () => {
|
it('returns empty array when user has no keys', async () => {
|
||||||
profileFindFirst.mockResolvedValue(null);
|
licenseKeyFindMany.mockResolvedValue([]);
|
||||||
licenseKeyGroupBy.mockResolvedValue([]);
|
|
||||||
|
|
||||||
const locals = createLocals(mockUser() as any);
|
const locals = createLocals(mockUser() as any);
|
||||||
const result = (await load({ locals } as any)) 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 () => {
|
it('loads license keys filtered by user, newest first', async () => {
|
||||||
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
|
licenseKeyFindMany.mockResolvedValue([]);
|
||||||
licenseKeyGroupBy.mockResolvedValue([
|
|
||||||
{ active: true, _count: 3 },
|
|
||||||
{ active: false, _count: 2 }
|
|
||||||
]);
|
|
||||||
|
|
||||||
const locals = createLocals(mockUser() as any);
|
const locals = createLocals(mockUser() as any);
|
||||||
const result = (await load({ locals } as any)) as any;
|
await load({ locals } as any);
|
||||||
expect(result.activeKeys).toBe(3);
|
|
||||||
expect(result.totalKeys).toBe(5);
|
expect(licenseKeyFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: 'user-1' },
|
||||||
|
orderBy: { createdAt: 'desc' }
|
||||||
});
|
});
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+72
@@ -90,5 +90,77 @@ export const actions: Actions = {
|
|||||||
console.error('Address create error:', err);
|
console.error('Address create error:', err);
|
||||||
return fail(500, { error: 'Failed to add address.', action: 'addAddress' });
|
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>
|
||||||
+168
-2
@@ -11,7 +11,7 @@ vi.mock('$lib/db', () => ({
|
|||||||
prisma: {
|
prisma: {
|
||||||
user: { findUnique: vi.fn() },
|
user: { findUnique: vi.fn() },
|
||||||
profile: { findFirst: vi.fn(), updateMany: 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 profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
|
||||||
const profileUpdateMany = prisma.profile.updateMany 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 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) =>
|
const invokeAction = (name: string, request: Request, locals: App.Locals) =>
|
||||||
(actions as any)[name]({ request, locals } as any);
|
(actions as any)[name]({ request, locals } as any);
|
||||||
|
|
||||||
describe('(protected)/account page server', () => {
|
describe('(protected)/profile page server', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
userFindUnique.mockReset();
|
userFindUnique.mockReset();
|
||||||
profileFindFirst.mockReset();
|
profileFindFirst.mockReset();
|
||||||
profileUpdateMany.mockReset();
|
profileUpdateMany.mockReset();
|
||||||
addressCreate.mockReset();
|
addressCreate.mockReset();
|
||||||
|
addressFindFirst.mockReset();
|
||||||
|
addressUpdate.mockReset();
|
||||||
|
addressDelete.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('load', () => {
|
describe('load', () => {
|
||||||
@@ -254,4 +260,164 @@ describe('(protected)/account page server', () => {
|
|||||||
expect((result as any).status).toBe(500);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+15
-9
@@ -43,7 +43,7 @@
|
|||||||
|
|
||||||
<Window>
|
<Window>
|
||||||
<form method="POST" class="flex flex-col items-center gap-5">
|
<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
|
Please finish setting up your profile
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -55,44 +55,50 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<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() ?? '?'}
|
{data.user?.name?.charAt(0)?.toUpperCase() ?? '?'}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<div class="flex w-full flex-col gap-4">
|
||||||
<input type="hidden" name="userId" value={data.user?.id} />
|
<input type="hidden" name="userId" value={data.user?.id} />
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div class="flex flex-col gap-1.5">
|
<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
|
<input
|
||||||
id="firstName"
|
id="firstName"
|
||||||
type="text"
|
type="text"
|
||||||
name="firstName"
|
name="firstName"
|
||||||
placeholder="First Name"
|
placeholder="First Name"
|
||||||
bind:value={firstName}
|
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>
|
||||||
<div class="flex flex-col gap-1.5">
|
<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
|
<input
|
||||||
id="lastName"
|
id="lastName"
|
||||||
type="text"
|
type="text"
|
||||||
name="lastName"
|
name="lastName"
|
||||||
placeholder="Last Name"
|
placeholder="Last Name"
|
||||||
bind:value={lastName}
|
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>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1.5">
|
<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
|
<input
|
||||||
id="phoneNumber"
|
id="phoneNumber"
|
||||||
type="tel"
|
type="tel"
|
||||||
@@ -100,7 +106,7 @@
|
|||||||
placeholder="(555) 555-5555"
|
placeholder="(555) 555-5555"
|
||||||
bind:value={phoneNumber}
|
bind:value={phoneNumber}
|
||||||
oninput={handlePhoneInput}
|
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>
|
||||||
</div>
|
</div>
|
||||||
+2
-2
@@ -27,7 +27,7 @@ import { load, actions } from './+page.server';
|
|||||||
|
|
||||||
const invokeDefault = (request: Request) => actions.default({ request } as any);
|
const invokeDefault = (request: Request) => actions.default({ request } as any);
|
||||||
|
|
||||||
describe('account setup page server load', () => {
|
describe('profile setup page server load', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
create.mockReset();
|
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(() => {
|
beforeEach(() => {
|
||||||
create.mockReset();
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,9 +14,9 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
|
|||||||
if (!user) {
|
if (!user) {
|
||||||
throw error(403, 'not authorized');
|
throw error(403, 'not authorized');
|
||||||
}
|
}
|
||||||
if (user.profiles.length === 0 && !url.pathname.startsWith('/account/setup')) {
|
if (user.profiles.length === 0 && !url.pathname.startsWith('/profile/setup')) {
|
||||||
// Skip redirect when already on /account/setup to avoid redirect loop
|
// Skip redirect when already on /profile/setup to avoid redirect loop
|
||||||
throw redirect(303, '/account/setup');
|
throw redirect(303, '/profile/setup');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { userRoleId: user.roleId };
|
return { userRoleId: user.roleId };
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
let { children }: { children: Snippet } = $props();
|
let { children }: { children: Snippet } = $props();
|
||||||
</script>
|
</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">
|
<main class="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 sm:py-8">
|
||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</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 type { PageServerLoad } from './$types';
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
import { sendMail } from '$lib/server/resend';
|
import { sendMail } from '$lib/server/resend';
|
||||||
|
import { isStaff } from '$lib/server/roles';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
|
||||||
function generateLicenseKey(): string {
|
function generateLicenseKey(): string {
|
||||||
@@ -18,7 +19,7 @@ export const load: PageServerLoad = async () => {
|
|||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
createKey: async ({ request, locals }) => {
|
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 formData = await request.formData();
|
||||||
const email = formData.get('email')?.toString()?.trim().toLowerCase();
|
const email = formData.get('email')?.toString()?.trim().toLowerCase();
|
||||||
@@ -60,7 +61,7 @@ export const actions = {
|
|||||||
return { success: true, action: 'createKey' };
|
return { success: true, action: 'createKey' };
|
||||||
},
|
},
|
||||||
replace: async ({ request, locals }) => {
|
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 formData = await request.formData();
|
||||||
const keyId = formData.get('id')?.toString();
|
const keyId = formData.get('id')?.toString();
|
||||||
|
|||||||
@@ -35,8 +35,20 @@
|
|||||||
let replaceError = $state('');
|
let replaceError = $state('');
|
||||||
let replaceSuccess = $state('');
|
let replaceSuccess = $state('');
|
||||||
|
|
||||||
let lastCreateForm = $state<ActionResult>(null);
|
let lastCreateForm = $state.raw<ActionResult>(null);
|
||||||
let lastReplaceForm = $state<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(() => {
|
$effect(() => {
|
||||||
if (!form) return;
|
if (!form) return;
|
||||||
@@ -48,9 +60,10 @@
|
|||||||
email = '';
|
email = '';
|
||||||
createError = '';
|
createError = '';
|
||||||
createSuccess = 'License key created and emailed';
|
createSuccess = 'License key created and emailed';
|
||||||
setTimeout(() => {
|
createTimer = setTimeout(() => {
|
||||||
createOpen = false;
|
createOpen = false;
|
||||||
createSuccess = '';
|
createSuccess = '';
|
||||||
|
createTimer = undefined;
|
||||||
}, 1200);
|
}, 1200);
|
||||||
} else {
|
} else {
|
||||||
createSuccess = '';
|
createSuccess = '';
|
||||||
@@ -62,9 +75,10 @@
|
|||||||
if (form.success) {
|
if (form.success) {
|
||||||
replaceError = '';
|
replaceError = '';
|
||||||
replaceSuccess = 'License key replaced';
|
replaceSuccess = 'License key replaced';
|
||||||
setTimeout(() => {
|
replaceTimer = setTimeout(() => {
|
||||||
replaceOpen = false;
|
replaceOpen = false;
|
||||||
replaceSuccess = '';
|
replaceSuccess = '';
|
||||||
|
replaceTimer = undefined;
|
||||||
}, 1200);
|
}, 1200);
|
||||||
} else {
|
} else {
|
||||||
replaceSuccess = '';
|
replaceSuccess = '';
|
||||||
@@ -74,25 +88,30 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
lastCreateForm = null;
|
clearTimer('create');
|
||||||
|
lastCreateForm = form;
|
||||||
|
email = '';
|
||||||
createError = '';
|
createError = '';
|
||||||
createSuccess = '';
|
createSuccess = '';
|
||||||
createOpen = true;
|
createOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openReplace(record: Record<string, unknown>) {
|
function openReplace(record: Record<string, unknown>) {
|
||||||
|
clearTimer('replace');
|
||||||
replaceRecord = record;
|
replaceRecord = record;
|
||||||
lastReplaceForm = null;
|
lastReplaceForm = form;
|
||||||
replaceError = '';
|
replaceError = '';
|
||||||
replaceSuccess = '';
|
replaceSuccess = '';
|
||||||
replaceOpen = true;
|
replaceOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeCreate() {
|
function closeCreate() {
|
||||||
|
clearTimer('create');
|
||||||
createOpen = false;
|
createOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeReplace() {
|
function closeReplace() {
|
||||||
|
clearTimer('replace');
|
||||||
replaceOpen = false;
|
replaceOpen = false;
|
||||||
replaceRecord = null;
|
replaceRecord = null;
|
||||||
}
|
}
|
||||||
@@ -114,20 +133,25 @@
|
|||||||
<AdminModal title="Create License Key" open={createOpen} onClose={closeCreate}>
|
<AdminModal title="Create License Key" open={createOpen} onClose={closeCreate}>
|
||||||
{#if createSuccess}
|
{#if createSuccess}
|
||||||
<div
|
<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}
|
{createSuccess}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if createError}
|
{#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}
|
{createError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST" action="?/createKey" use:enhance class="flex flex-col gap-4">
|
<form method="POST" action="?/createKey" use:enhance class="flex flex-col gap-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
id="field-email"
|
id="field-email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -135,9 +159,9 @@
|
|||||||
required
|
required
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
placeholder="user@example.com"
|
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.
|
If no account exists for this email yet, the key will be attached when they register.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -146,7 +170,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={closeCreate}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -163,23 +187,25 @@
|
|||||||
<AdminModal title="Replace License Key" open={replaceOpen} onClose={closeReplace}>
|
<AdminModal title="Replace License Key" open={replaceOpen} onClose={closeReplace}>
|
||||||
{#if replaceSuccess}
|
{#if replaceSuccess}
|
||||||
<div
|
<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}
|
{replaceSuccess}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if replaceError}
|
{#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}
|
{replaceError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if replaceRecord}
|
{#if replaceRecord}
|
||||||
<div class="flex flex-col gap-4">
|
<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>?
|
Generate a new key to replace <strong>{String(replaceRecord.key)}</strong>?
|
||||||
</p>
|
</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.
|
The existing key will be deleted and a new one will be created.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -189,7 +215,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={closeReplace}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ describe('admin license-keys createKey action', () => {
|
|||||||
const req = createFormRequest({ email: 'jane@example.com' });
|
const req = createFormRequest({ email: 'jane@example.com' });
|
||||||
|
|
||||||
const err = await actions.createKey({ request: req, locals } as any).catch((e) => e);
|
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 () => {
|
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 req = createFormRequest({});
|
||||||
|
|
||||||
const result = await actions.createKey({ request: req, locals } as any);
|
const result = await actions.createKey({ request: req, locals } as any);
|
||||||
@@ -65,7 +65,7 @@ describe('admin license-keys createKey action', () => {
|
|||||||
mockLicenseKeyCreate.mockResolvedValue({});
|
mockLicenseKeyCreate.mockResolvedValue({});
|
||||||
sendMail.mockResolvedValue({ id: 'email-1' });
|
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 req = createFormRequest({ email: 'nobody@example.com' });
|
||||||
|
|
||||||
const result = await actions.createKey({ request: req, locals } as any);
|
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 () => {
|
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' };
|
const profile = { firstName: 'Jane' };
|
||||||
|
|
||||||
mockFindUnique.mockResolvedValue(user);
|
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 () => {
|
it('sends email with license_key even when profile is missing', async () => {
|
||||||
const user = mockUser();
|
const user = mockUser({ roleId: 3 });
|
||||||
|
|
||||||
mockFindUnique.mockResolvedValue(user);
|
mockFindUnique.mockResolvedValue(user);
|
||||||
mockProfileFindFirst.mockResolvedValue(null);
|
mockProfileFindFirst.mockResolvedValue(null);
|
||||||
@@ -136,7 +136,7 @@ describe('admin license-keys createKey action', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('still returns success when email sending fails', async () => {
|
it('still returns success when email sending fails', async () => {
|
||||||
const user = mockUser();
|
const user = mockUser({ roleId: 3 });
|
||||||
|
|
||||||
mockFindUnique.mockResolvedValue(user);
|
mockFindUnique.mockResolvedValue(user);
|
||||||
mockProfileFindFirst.mockResolvedValue({ firstName: 'Jane' });
|
mockProfileFindFirst.mockResolvedValue({ firstName: 'Jane' });
|
||||||
@@ -166,11 +166,11 @@ describe('admin license-keys replace action', () => {
|
|||||||
const req = createFormRequest({ id: 'k1' });
|
const req = createFormRequest({ id: 'k1' });
|
||||||
|
|
||||||
const err = await actions.replace({ request: req, locals } as any).catch((e) => e);
|
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 () => {
|
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 req = createFormRequest({});
|
||||||
|
|
||||||
const result = await actions.replace({ request: req, locals } as any);
|
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 () => {
|
it('returns error when the key does not exist', async () => {
|
||||||
mockLicenseKeyFindUnique.mockResolvedValue(null);
|
mockLicenseKeyFindUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
const locals = createLocals(mockUser() as any);
|
const locals = createLocals(mockUser({ roleId: 3 }) as any);
|
||||||
const req = createFormRequest({ id: 'missing' });
|
const req = createFormRequest({ id: 'missing' });
|
||||||
|
|
||||||
const result = await actions.replace({ request: req, locals } as any);
|
const result = await actions.replace({ request: req, locals } as any);
|
||||||
@@ -197,7 +197,7 @@ describe('admin license-keys replace action', () => {
|
|||||||
mockLicenseKeyDelete.mockResolvedValue({});
|
mockLicenseKeyDelete.mockResolvedValue({});
|
||||||
mockLicenseKeyCreate.mockResolvedValue({});
|
mockLicenseKeyCreate.mockResolvedValue({});
|
||||||
|
|
||||||
const locals = createLocals(mockUser() as any);
|
const locals = createLocals(mockUser({ roleId: 3 }) as any);
|
||||||
const req = createFormRequest({ id: 'k1' });
|
const req = createFormRequest({ id: 'k1' });
|
||||||
|
|
||||||
const result = await actions.replace({ request: req, locals } as any);
|
const result = await actions.replace({ request: req, locals } as any);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
|
import { isAdmin, isStaff } from '$lib/server/roles';
|
||||||
import { superValidate } from 'sveltekit-superforms/server';
|
import { superValidate } from 'sveltekit-superforms/server';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -17,7 +18,11 @@ export const load: PageServerLoad = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
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 formData = await request.formData();
|
||||||
const form = await superValidate(formData, zod4(CategorySchema));
|
const form = await superValidate(formData, zod4(CategorySchema));
|
||||||
if (!form.valid) {
|
if (!form.valid) {
|
||||||
@@ -58,7 +63,11 @@ export const actions = {
|
|||||||
|
|
||||||
return { success: true, action: 'upsert' };
|
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 formData = await request.formData();
|
||||||
const rawId = formData.get('id')?.toString();
|
const rawId = formData.get('id')?.toString();
|
||||||
if (!rawId) return { success: false, error: 'Missing id' };
|
if (!rawId) return { success: false, error: 'Missing id' };
|
||||||
@@ -77,7 +86,11 @@ export const actions = {
|
|||||||
|
|
||||||
return { success: true, action: 'delete' };
|
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 formData = await request.formData();
|
||||||
const raw = formData.get('ids')?.toString();
|
const raw = formData.get('ids')?.toString();
|
||||||
if (!raw) return { success: false, error: 'Missing ids' };
|
if (!raw) return { success: false, error: 'Missing ids' };
|
||||||
|
|||||||
@@ -24,17 +24,23 @@
|
|||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let successMessage = $state('');
|
let successMessage = $state('');
|
||||||
let lastForm = $state<ActionResult>(null);
|
let lastForm = $state.raw<ActionResult>(null);
|
||||||
|
|
||||||
$effect(() => {
|
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const rec = editingRecord ?? {};
|
|
||||||
name = String(rec.name ?? '');
|
function clearCloseTimer() {
|
||||||
if (!editingRecord) {
|
if (closeTimer) {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
closeTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetModalState() {
|
||||||
|
clearCloseTimer();
|
||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!form || form === lastForm) return;
|
if (!form || form === lastForm) return;
|
||||||
@@ -44,9 +50,10 @@
|
|||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
successMessage = editingRecord ? 'Category updated' : 'Category created';
|
successMessage = editingRecord ? 'Category updated' : 'Category created';
|
||||||
setTimeout(() => {
|
closeTimer = setTimeout(() => {
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
|
closeTimer = undefined;
|
||||||
}, 1200);
|
}, 1200);
|
||||||
} else {
|
} else {
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
@@ -63,18 +70,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
|
resetModalState();
|
||||||
editingRecord = null;
|
editingRecord = null;
|
||||||
lastForm = null;
|
name = '';
|
||||||
|
lastForm = form;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(record: Record<string, unknown>) {
|
function openEdit(record: Record<string, unknown>) {
|
||||||
|
resetModalState();
|
||||||
editingRecord = record;
|
editingRecord = record;
|
||||||
lastForm = null;
|
name = String(record.name ?? '');
|
||||||
|
lastForm = form;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
clearCloseTimer();
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
editingRecord = null;
|
editingRecord = null;
|
||||||
}
|
}
|
||||||
@@ -104,13 +116,15 @@
|
|||||||
>
|
>
|
||||||
{#if successMessage}
|
{#if successMessage}
|
||||||
<div
|
<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}
|
{successMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if formError}
|
{#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}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -121,17 +135,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<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
|
<input
|
||||||
id="field-name"
|
id="field-name"
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
required
|
required
|
||||||
bind:value={name}
|
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}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,7 +156,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={closeModal}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
|
import { isAdmin, isStaff } from '$lib/server/roles';
|
||||||
import { superValidate } from 'sveltekit-superforms/server';
|
import { superValidate } from 'sveltekit-superforms/server';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -17,7 +18,11 @@ export const load: PageServerLoad = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
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 formData = await request.formData();
|
||||||
const form = await superValidate(formData, zod4(GroupSchema));
|
const form = await superValidate(formData, zod4(GroupSchema));
|
||||||
if (!form.valid) {
|
if (!form.valid) {
|
||||||
@@ -58,7 +63,11 @@ export const actions = {
|
|||||||
|
|
||||||
return { success: true, action: 'upsert' };
|
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 formData = await request.formData();
|
||||||
const rawId = formData.get('id')?.toString();
|
const rawId = formData.get('id')?.toString();
|
||||||
if (!rawId) return { success: false, error: 'Missing id' };
|
if (!rawId) return { success: false, error: 'Missing id' };
|
||||||
@@ -77,7 +86,11 @@ export const actions = {
|
|||||||
|
|
||||||
return { success: true, action: 'delete' };
|
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 formData = await request.formData();
|
||||||
const raw = formData.get('ids')?.toString();
|
const raw = formData.get('ids')?.toString();
|
||||||
if (!raw) return { success: false, error: 'Missing ids' };
|
if (!raw) return { success: false, error: 'Missing ids' };
|
||||||
|
|||||||
@@ -24,17 +24,23 @@
|
|||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let successMessage = $state('');
|
let successMessage = $state('');
|
||||||
let lastForm = $state<ActionResult>(null);
|
let lastForm = $state.raw<ActionResult>(null);
|
||||||
|
|
||||||
$effect(() => {
|
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const rec = editingRecord ?? {};
|
|
||||||
name = String(rec.name ?? '');
|
function clearCloseTimer() {
|
||||||
if (!editingRecord) {
|
if (closeTimer) {
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
closeTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetModalState() {
|
||||||
|
clearCloseTimer();
|
||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!form || form === lastForm) return;
|
if (!form || form === lastForm) return;
|
||||||
@@ -44,9 +50,10 @@
|
|||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
successMessage = editingRecord ? 'Group updated' : 'Group created';
|
successMessage = editingRecord ? 'Group updated' : 'Group created';
|
||||||
setTimeout(() => {
|
closeTimer = setTimeout(() => {
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
|
closeTimer = undefined;
|
||||||
}, 1200);
|
}, 1200);
|
||||||
} else {
|
} else {
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
@@ -63,18 +70,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
|
resetModalState();
|
||||||
editingRecord = null;
|
editingRecord = null;
|
||||||
lastForm = null;
|
name = '';
|
||||||
|
lastForm = form;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(record: Record<string, unknown>) {
|
function openEdit(record: Record<string, unknown>) {
|
||||||
|
resetModalState();
|
||||||
editingRecord = record;
|
editingRecord = record;
|
||||||
lastForm = null;
|
name = String(record.name ?? '');
|
||||||
|
lastForm = form;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
clearCloseTimer();
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
editingRecord = null;
|
editingRecord = null;
|
||||||
}
|
}
|
||||||
@@ -104,13 +116,15 @@
|
|||||||
>
|
>
|
||||||
{#if successMessage}
|
{#if successMessage}
|
||||||
<div
|
<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}
|
{successMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if formError}
|
{#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}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -121,17 +135,20 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<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
|
<input
|
||||||
id="field-name"
|
id="field-name"
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
required
|
required
|
||||||
bind:value={name}
|
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}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,7 +156,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={closeModal}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
|
import { isSuper } from '$lib/server/roles';
|
||||||
import { superValidate } from 'sveltekit-superforms/server';
|
import { superValidate } from 'sveltekit-superforms/server';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -13,10 +14,6 @@ const ProclamationSchema = z.object({
|
|||||||
content: z.string().optional().default('')
|
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 }) => {
|
export const load: PageServerLoad = async ({ locals }) => {
|
||||||
const [proclamations, frequencies, categories, groups] = await Promise.all([
|
const [proclamations, frequencies, categories, groups] = await Promise.all([
|
||||||
prisma.proclamation.findMany({
|
prisma.proclamation.findMany({
|
||||||
|
|||||||
@@ -46,26 +46,28 @@
|
|||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let successMessage = $state('');
|
let successMessage = $state('');
|
||||||
let lastForm = $state<ActionResult>(null);
|
let lastForm = $state.raw<ActionResult>(null);
|
||||||
|
|
||||||
let element = $state<HTMLElement>();
|
let element = $state<HTMLElement>();
|
||||||
let editor: Editor | null = null;
|
let editor: Editor | null = null;
|
||||||
|
|
||||||
let contentKey = $derived(editingRecord?.id ?? 'new');
|
let contentKey = $derived(editingRecord?.id ?? 'new');
|
||||||
|
|
||||||
$effect(() => {
|
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
const record = editingRecord ?? {};
|
|
||||||
name = String(record.name ?? '');
|
function clearCloseTimer() {
|
||||||
solfeggioFrequencyId = String(record.solfeggioFrequencyId ?? '');
|
if (closeTimer) {
|
||||||
categoryId = String(record.categoryId ?? '');
|
clearTimeout(closeTimer);
|
||||||
groupId = String(record.groupId ?? '');
|
closeTimer = undefined;
|
||||||
content = typeof record.content === 'string' ? record.content : '';
|
}
|
||||||
if (!editingRecord) {
|
}
|
||||||
|
|
||||||
|
function resetModalState() {
|
||||||
|
clearCloseTimer();
|
||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!form || form === lastForm) return;
|
if (!form || form === lastForm) return;
|
||||||
@@ -75,6 +77,11 @@
|
|||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
successMessage = editingRecord ? 'Proclamation updated' : 'Proclamation created';
|
successMessage = editingRecord ? 'Proclamation updated' : 'Proclamation created';
|
||||||
|
closeTimer = setTimeout(() => {
|
||||||
|
modalOpen = false;
|
||||||
|
successMessage = '';
|
||||||
|
closeTimer = undefined;
|
||||||
|
}, 1200);
|
||||||
} else {
|
} else {
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
formError = form.error ?? '';
|
formError = form.error ?? '';
|
||||||
@@ -133,18 +140,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
|
resetModalState();
|
||||||
editingRecord = null;
|
editingRecord = null;
|
||||||
lastForm = null;
|
name = '';
|
||||||
|
solfeggioFrequencyId = '';
|
||||||
|
categoryId = '';
|
||||||
|
groupId = '';
|
||||||
|
content = '';
|
||||||
|
lastForm = form;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(record: Record<string, unknown>) {
|
function openEdit(record: Record<string, unknown>) {
|
||||||
|
resetModalState();
|
||||||
editingRecord = record;
|
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;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
clearCloseTimer();
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
editingRecord = null;
|
editingRecord = null;
|
||||||
}
|
}
|
||||||
@@ -172,13 +192,15 @@
|
|||||||
>
|
>
|
||||||
{#if successMessage}
|
{#if successMessage}
|
||||||
<div
|
<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}
|
{successMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if formError}
|
{#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}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -189,31 +211,34 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div>
|
<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
|
<input
|
||||||
id="field-name"
|
id="field-name"
|
||||||
type="text"
|
type="text"
|
||||||
name="name"
|
name="name"
|
||||||
required
|
required
|
||||||
bind:value={name}
|
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}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="field-solfeggioFrequencyId"
|
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
|
<select
|
||||||
id="field-solfeggioFrequencyId"
|
id="field-solfeggioFrequencyId"
|
||||||
name="solfeggioFrequencyId"
|
name="solfeggioFrequencyId"
|
||||||
required
|
required
|
||||||
bind:value={solfeggioFrequencyId}
|
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>
|
<option value="">-- Select Frequency --</option>
|
||||||
{#each data.frequencies as freq (String(freq.id))}
|
{#each data.frequencies as freq (String(freq.id))}
|
||||||
@@ -221,20 +246,23 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{#if fieldErrors.solfeggioFrequencyId}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="field-categoryId" class="mb-1 block text-sm font-medium text-slate-700"
|
<label
|
||||||
>Category</label
|
for="field-categoryId"
|
||||||
|
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Category</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
id="field-categoryId"
|
id="field-categoryId"
|
||||||
name="categoryId"
|
name="categoryId"
|
||||||
required
|
required
|
||||||
bind:value={categoryId}
|
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>
|
<option value="">-- Select Category --</option>
|
||||||
{#each data.categories as cat (String(cat.id))}
|
{#each data.categories as cat (String(cat.id))}
|
||||||
@@ -242,20 +270,21 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{#if fieldErrors.categoryId}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="field-groupId" class="mb-1 block text-sm font-medium text-slate-700"
|
<label
|
||||||
>Group</label
|
for="field-groupId"
|
||||||
|
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Group</label
|
||||||
>
|
>
|
||||||
<select
|
<select
|
||||||
id="field-groupId"
|
id="field-groupId"
|
||||||
name="groupId"
|
name="groupId"
|
||||||
required
|
required
|
||||||
bind:value={groupId}
|
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>
|
<option value="">-- Select Group --</option>
|
||||||
{#each data.groups as group (String(group.id))}
|
{#each data.groups as group (String(group.id))}
|
||||||
@@ -263,42 +292,45 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{#if fieldErrors.groupId}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="field-content" class="mb-1 block text-sm font-medium text-slate-700"
|
<label
|
||||||
>Content</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
|
||||||
<div class="flex gap-1 border-b border-slate-200 px-2 py-1.5">
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleBold}
|
onclick={toggleBold}
|
||||||
title="Bold"
|
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
|
>B</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleItalic}
|
onclick={toggleItalic}
|
||||||
title="Italic"
|
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
|
>I</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleHighlight}
|
onclick={toggleHighlight}
|
||||||
title="Highlight"
|
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
|
>H</button
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={setColor}
|
onclick={setColor}
|
||||||
title="Cycle text color"
|
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
|
>A</button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,13 +341,13 @@
|
|||||||
role="textbox"
|
role="textbox"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
contenteditable="true"
|
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>
|
></div>
|
||||||
{/key}
|
{/key}
|
||||||
<input type="hidden" name="content" value={content} />
|
<input type="hidden" name="content" value={content} />
|
||||||
</div>
|
</div>
|
||||||
{#if fieldErrors.content}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -323,7 +355,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={closeModal}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad } from './$types';
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
|
import { isAdmin, isStaff } from '$lib/server/roles';
|
||||||
import { superValidate } from 'sveltekit-superforms/server';
|
import { superValidate } from 'sveltekit-superforms/server';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -21,6 +22,7 @@ const ProfileSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const AddressSchema = z.object({
|
const AddressSchema = z.object({
|
||||||
|
id: z.string().optional(),
|
||||||
profileId: z.string().min(1),
|
profileId: z.string().min(1),
|
||||||
street: z.string().min(1, 'Street is required'),
|
street: z.string().min(1, 'Street is required'),
|
||||||
city: z.string().min(1, 'City is required'),
|
city: z.string().min(1, 'City is required'),
|
||||||
@@ -45,7 +47,11 @@ export const load: PageServerLoad = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
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 formData = await request.formData();
|
||||||
const form = await superValidate(formData, zod4(ProfileSchema));
|
const form = await superValidate(formData, zod4(ProfileSchema));
|
||||||
if (!form.valid) {
|
if (!form.valid) {
|
||||||
@@ -112,14 +118,22 @@ export const actions = {
|
|||||||
}
|
}
|
||||||
return { success: true, action: 'upsert' };
|
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 formData = await request.formData();
|
||||||
const rawId = formData.get('id')?.toString();
|
const rawId = formData.get('id')?.toString();
|
||||||
if (!rawId) return { success: false, error: 'Missing id' };
|
if (!rawId) return { success: false, error: 'Missing id' };
|
||||||
await prisma.profile.delete({ where: { id: rawId } });
|
await prisma.profile.delete({ where: { id: rawId } });
|
||||||
return { success: true, action: 'delete' };
|
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 formData = await request.formData();
|
||||||
const form = await superValidate(formData, zod4(AddressSchema));
|
const form = await superValidate(formData, zod4(AddressSchema));
|
||||||
if (!form.valid) {
|
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;
|
form.data;
|
||||||
|
|
||||||
if (!profileId) {
|
if (!profileId) {
|
||||||
@@ -168,7 +182,36 @@ export const actions = {
|
|||||||
isDefaultShipping
|
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 });
|
const created = await prisma.address.create({ data: payload });
|
||||||
return { success: true, action: 'upsertAddress', address: created };
|
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 formError = $state('');
|
||||||
let fieldErrors = $state<Record<string, string>>({});
|
let fieldErrors = $state<Record<string, string>>({});
|
||||||
let successMessage = $state('');
|
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(() => {
|
$effect(() => {
|
||||||
|
clearCloseTimer();
|
||||||
if (open) {
|
if (open) {
|
||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
@@ -37,11 +47,19 @@
|
|||||||
if (!form || form === lastForm) return;
|
if (!form || form === lastForm) return;
|
||||||
lastForm = form;
|
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) {
|
if (form.success) {
|
||||||
formError = '';
|
formError = '';
|
||||||
fieldErrors = {};
|
fieldErrors = {};
|
||||||
successMessage = 'Profile created';
|
successMessage = 'Profile created';
|
||||||
setTimeout(() => onClose?.(), 1200);
|
closeTimer = setTimeout(() => {
|
||||||
|
closeTimer = undefined;
|
||||||
|
onClose?.();
|
||||||
|
}, 1200);
|
||||||
} else {
|
} else {
|
||||||
successMessage = '';
|
successMessage = '';
|
||||||
formError = form.error ?? '';
|
formError = form.error ?? '';
|
||||||
@@ -53,13 +71,15 @@
|
|||||||
<AdminModal title="Create Profile" {open} {onClose}>
|
<AdminModal title="Create Profile" {open} {onClose}>
|
||||||
{#if successMessage}
|
{#if successMessage}
|
||||||
<div
|
<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}
|
{successMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if formError}
|
{#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}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -71,7 +91,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||||
import ProfileFields from './ProfileFields.svelte';
|
import ProfileFields from './ProfileFields.svelte';
|
||||||
@@ -10,6 +11,7 @@
|
|||||||
fieldErrors?: Record<string, string>;
|
fieldErrors?: Record<string, string>;
|
||||||
addressError?: string;
|
addressError?: string;
|
||||||
address?: Record<string, unknown>;
|
address?: Record<string, unknown>;
|
||||||
|
addressId?: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -31,6 +33,7 @@
|
|||||||
let addressError = $state('');
|
let addressError = $state('');
|
||||||
let successMessage = $state('');
|
let successMessage = $state('');
|
||||||
let addressFormOpen = $state(false);
|
let addressFormOpen = $state(false);
|
||||||
|
let editingAddressId = $state<string | null>(null);
|
||||||
let addresses = $state<Record<string, unknown>[]>([]);
|
let addresses = $state<Record<string, unknown>[]>([]);
|
||||||
|
|
||||||
let addrStreet = $state('');
|
let addrStreet = $state('');
|
||||||
@@ -43,9 +46,12 @@
|
|||||||
let addrShipping = $state(false);
|
let addrShipping = $state(false);
|
||||||
|
|
||||||
let lastRecordId = $state<string | null>(null);
|
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() {
|
function resetAddressFields() {
|
||||||
|
editingAddressId = null;
|
||||||
addrStreet = '';
|
addrStreet = '';
|
||||||
addrCity = '';
|
addrCity = '';
|
||||||
addrState = '';
|
addrState = '';
|
||||||
@@ -86,7 +92,11 @@
|
|||||||
switch (form.action) {
|
switch (form.action) {
|
||||||
case 'upsertAddress':
|
case 'upsertAddress':
|
||||||
if (form.success && form.address) {
|
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;
|
addressFormOpen = false;
|
||||||
addressError = '';
|
addressError = '';
|
||||||
successMessage = 'Address saved';
|
successMessage = 'Address saved';
|
||||||
@@ -96,6 +106,16 @@
|
|||||||
addressError = form.addressError ?? '';
|
addressError = form.addressError ?? '';
|
||||||
}
|
}
|
||||||
break;
|
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':
|
case 'upsert':
|
||||||
default:
|
default:
|
||||||
if (form.success) {
|
if (form.success) {
|
||||||
@@ -116,18 +136,35 @@
|
|||||||
addressError = '';
|
addressError = '';
|
||||||
if (addressFormOpen) resetAddressFields();
|
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>
|
</script>
|
||||||
|
|
||||||
<AdminModal title="Edit Profile" {open} {onClose} {canDelete} recordId={String(record.id ?? '')}>
|
<AdminModal title="Edit Profile" {open} {onClose} {canDelete} recordId={String(record.id ?? '')}>
|
||||||
{#if successMessage}
|
{#if successMessage}
|
||||||
<div
|
<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}
|
{successMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if formError}
|
{#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}
|
{formError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -141,7 +178,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={onClose}
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -154,13 +191,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={toggleAddressForm}
|
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 +'}
|
{addressFormOpen ? 'Cancel' : 'Add Address +'}
|
||||||
</button>
|
</button>
|
||||||
@@ -169,14 +206,21 @@
|
|||||||
{#if addressFormOpen}
|
{#if addressFormOpen}
|
||||||
<form method="POST" action="?/upsertAddress" use:enhance class="mt-4 flex flex-col gap-4">
|
<form method="POST" action="?/upsertAddress" use:enhance class="mt-4 flex flex-col gap-4">
|
||||||
<input type="hidden" name="profileId" value={String(record.id ?? '')} />
|
<input type="hidden" name="profileId" value={String(record.id ?? '')} />
|
||||||
|
{#if editingAddressId}
|
||||||
|
<input type="hidden" name="id" value={editingAddressId} />
|
||||||
|
{/if}
|
||||||
{#if addressError}
|
{#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}
|
{addressError}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div>
|
<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
|
>Street</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -185,11 +229,13 @@
|
|||||||
name="street"
|
name="street"
|
||||||
required
|
required
|
||||||
bind:value={addrStreet}
|
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>
|
||||||
<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
|
<input
|
||||||
id="addr-city"
|
id="addr-city"
|
||||||
@@ -197,12 +243,13 @@
|
|||||||
name="city"
|
name="city"
|
||||||
required
|
required
|
||||||
bind:value={addrCity}
|
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>
|
||||||
<div>
|
<div>
|
||||||
<label for="addr-state" class="mb-1 block text-sm font-medium text-slate-700"
|
<label
|
||||||
>State</label
|
for="addr-state"
|
||||||
|
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">State</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="addr-state"
|
id="addr-state"
|
||||||
@@ -210,11 +257,13 @@
|
|||||||
name="state"
|
name="state"
|
||||||
required
|
required
|
||||||
bind:value={addrState}
|
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>
|
||||||
<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
|
>Postal Code</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -223,11 +272,13 @@
|
|||||||
name="postalCode"
|
name="postalCode"
|
||||||
required
|
required
|
||||||
bind:value={addrPostal}
|
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>
|
||||||
<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
|
>Country</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -236,11 +287,11 @@
|
|||||||
name="country"
|
name="country"
|
||||||
required
|
required
|
||||||
bind:value={addrCountry}
|
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>
|
</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">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="isDefaultMailing" bind:checked={addrMailing} />
|
<input type="checkbox" name="isDefaultMailing" bind:checked={addrMailing} />
|
||||||
Default Mailing
|
Default Mailing
|
||||||
@@ -257,20 +308,20 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if addresses.length > 0}
|
{#if addresses.length > 0}
|
||||||
<div class="mt-4 overflow-x-auto rounded-md border border-slate-200">
|
<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">
|
<table class="w-full min-w-max text-left text-sm text-slate-700 dark:text-slate-300">
|
||||||
<thead>
|
<thead>
|
||||||
<tr
|
<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">Street</th>
|
||||||
<th scope="col" class="px-3 py-2">City</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">M</th>
|
||||||
<th scope="col" class="px-3 py-2">B</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">S</th>
|
||||||
|
<th scope="col" class="px-3 py-2">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each addresses as addr, i (String(addr.id ?? i))}
|
{#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.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.city as string}</td>
|
||||||
<td class="px-3 py-2 whitespace-nowrap">{addr.state 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.isDefaultMailing ? '✓' : ''}</td>
|
||||||
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultBilling ? '✓' : ''}</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">{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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{: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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</AdminModal>
|
</AdminModal>
|
||||||
|
|||||||
@@ -24,7 +24,10 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<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
|
<input
|
||||||
id="field-email"
|
id="field-email"
|
||||||
type="email"
|
type="email"
|
||||||
@@ -32,16 +35,17 @@
|
|||||||
required
|
required
|
||||||
bind:value={email}
|
bind:value={email}
|
||||||
placeholder="user@example.com"
|
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}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="field-firstName" class="mb-1 block text-sm font-medium text-slate-700"
|
<label
|
||||||
>First Name</label
|
for="field-firstName"
|
||||||
|
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">First Name</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="field-firstName"
|
id="field-firstName"
|
||||||
@@ -49,16 +53,17 @@
|
|||||||
name="firstName"
|
name="firstName"
|
||||||
required
|
required
|
||||||
bind:value={firstName}
|
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}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="field-lastName" class="mb-1 block text-sm font-medium text-slate-700"
|
<label
|
||||||
>Last Name</label
|
for="field-lastName"
|
||||||
|
class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300">Last Name</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
id="field-lastName"
|
id="field-lastName"
|
||||||
@@ -66,15 +71,18 @@
|
|||||||
name="lastName"
|
name="lastName"
|
||||||
required
|
required
|
||||||
bind:value={lastName}
|
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}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
Phone Number
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -83,10 +91,10 @@
|
|||||||
name="phoneNumber"
|
name="phoneNumber"
|
||||||
required
|
required
|
||||||
bind:value={phoneNumber}
|
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}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
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', () => ({
|
vi.mock('$lib/db', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
@@ -14,6 +14,12 @@ vi.mock('$lib/db', () => ({
|
|||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
update: 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 mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||||
const mockUserFindFirst = prisma.user.findFirst 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 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 = {
|
const validFields = {
|
||||||
email: 'jane@example.com',
|
email: 'jane@example.com',
|
||||||
@@ -41,6 +51,9 @@ function makeProfileRequest(overrides: Record<string, string> = {}) {
|
|||||||
return createFormRequest({ ...validFields, ...overrides });
|
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', () => {
|
describe('profiles admin page server', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -73,7 +86,10 @@ describe('profiles admin page server', () => {
|
|||||||
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: validFields.email });
|
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: validFields.email });
|
||||||
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
|
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(result).toEqual({ success: true, action: 'upsert' });
|
||||||
expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { email: validFields.email } });
|
expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { email: validFields.email } });
|
||||||
@@ -86,7 +102,10 @@ describe('profiles admin page server', () => {
|
|||||||
mockUserFindUnique.mockResolvedValue(null);
|
mockUserFindUnique.mockResolvedValue(null);
|
||||||
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
|
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(result).toEqual({ success: true, action: 'upsert' });
|
||||||
expect(mockProfileCreate).toHaveBeenCalledWith({ data: validFields });
|
expect(mockProfileCreate).toHaveBeenCalledWith({ data: validFields });
|
||||||
@@ -96,7 +115,10 @@ describe('profiles admin page server', () => {
|
|||||||
mockUserFindUnique.mockResolvedValue(null);
|
mockUserFindUnique.mockResolvedValue(null);
|
||||||
mockProfileCreate.mockRejectedValue({ code: 'P2002' });
|
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({
|
expect(result).toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -119,7 +141,7 @@ describe('profiles admin page server', () => {
|
|||||||
mockProfileUpdate.mockResolvedValue({});
|
mockProfileUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
const request = makeProfileRequest({ email: 'new@example.com', id: 'p1' });
|
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(result).toEqual({ success: true, action: 'upsert' });
|
||||||
expect(mockUserUpdate).toHaveBeenCalledWith({
|
expect(mockUserUpdate).toHaveBeenCalledWith({
|
||||||
@@ -142,7 +164,7 @@ describe('profiles admin page server', () => {
|
|||||||
mockProfileUpdate.mockResolvedValue({});
|
mockProfileUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
const request = makeProfileRequest({ id: 'p1' });
|
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(result).toEqual({ success: true, action: 'upsert' });
|
||||||
expect(mockUserFindFirst).not.toHaveBeenCalled();
|
expect(mockUserFindFirst).not.toHaveBeenCalled();
|
||||||
@@ -159,7 +181,7 @@ describe('profiles admin page server', () => {
|
|||||||
mockUserFindFirst.mockResolvedValue({ id: 'user-2' });
|
mockUserFindFirst.mockResolvedValue({ id: 'user-2' });
|
||||||
|
|
||||||
const request = makeProfileRequest({ email: 'taken@example.com', id: 'p1' });
|
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({
|
expect(result).toEqual({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -181,7 +203,7 @@ describe('profiles admin page server', () => {
|
|||||||
mockProfileUpdate.mockResolvedValue({});
|
mockProfileUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
const request = makeProfileRequest({ id: 'p1' });
|
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(result).toEqual({ success: true, action: 'upsert' });
|
||||||
expect(mockProfileUpdate).toHaveBeenCalledWith({
|
expect(mockProfileUpdate).toHaveBeenCalledWith({
|
||||||
@@ -194,7 +216,7 @@ describe('profiles admin page server', () => {
|
|||||||
describe('upsert - validation', () => {
|
describe('upsert - validation', () => {
|
||||||
it('returns field errors for invalid input', async () => {
|
it('returns field errors for invalid input', async () => {
|
||||||
const request = makeProfileRequest({ email: 'not-an-email' });
|
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.success).toBe(false);
|
||||||
expect(result.action).toBe('upsert');
|
expect(result.action).toBe('upsert');
|
||||||
@@ -205,7 +227,7 @@ describe('profiles admin page server', () => {
|
|||||||
mockProfileFindUnique.mockResolvedValue(null);
|
mockProfileFindUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
const request = makeProfileRequest({ id: 'missing' });
|
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' });
|
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 () => {
|
it('deletes the profile by id', async () => {
|
||||||
mockProfileDelete.mockResolvedValue({});
|
mockProfileDelete.mockResolvedValue({});
|
||||||
|
|
||||||
const locals = createLocals();
|
const locals = admin();
|
||||||
const req = createFormRequest({ id: 'p1' });
|
const req = createFormRequest({ id: 'p1' });
|
||||||
const result = await actions.delete({ request: req, locals } as any);
|
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 () => {
|
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);
|
const result = await actions.delete({ request: createFormRequest({}), locals } as any);
|
||||||
|
|
||||||
expect(result).toEqual({ success: false, error: 'Missing id' });
|
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 type { PageServerLoad } from './$types';
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
|
import { isAdmin, isStaff } from '$lib/server/roles';
|
||||||
import { superValidate } from 'sveltekit-superforms/server';
|
import { superValidate } from 'sveltekit-superforms/server';
|
||||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -16,7 +17,11 @@ export const load: PageServerLoad = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const actions = {
|
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 formData = await request.formData();
|
||||||
const form = await superValidate(formData, zod4(SolfeggioSchema));
|
const form = await superValidate(formData, zod4(SolfeggioSchema));
|
||||||
if (!form.valid) {
|
if (!form.valid) {
|
||||||
@@ -48,7 +53,11 @@ export const actions = {
|
|||||||
|
|
||||||
return { success: true, action: 'upsert' };
|
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 formData = await request.formData();
|
||||||
const rawId = formData.get('id')?.toString();
|
const rawId = formData.get('id')?.toString();
|
||||||
if (!rawId) return { success: false, error: 'Missing id' };
|
if (!rawId) return { success: false, error: 'Missing id' };
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user