Rebuild admin pages around DataTable/AdminModal; restructure categories/groups and v2 groups API

Remove the generic $lib/svelte-admin CRUD layer and AdminTable.svelte; each
admin route now owns its own form/modal logic. Add DataTable.svelte,
superform-errors.ts, and profile-specific modal/field components.

Flatten proclamation categories/groups and drop subscription levels; add the
v2 groups API; update v1/v2 endpoints, serializers, auth, and regenerated
Prisma client with the new migrations.

Document the overhaul plan in docs/ADMIN_OVERHAUL.md.
This commit is contained in:
NeilClack
2026-09-10 09:38:01 -05:00
parent f4e736c92d
commit 91d162c746
127 changed files with 7182 additions and 9785 deletions
+3
View File
@@ -7,3 +7,6 @@ bun.lockb
# Miscellaneous
/static/
# Generated code
/src/lib/generated/
+30 -33
View File
@@ -9,11 +9,11 @@ AGENTS.md
at your project root:
````text
# Rhemawave Portal AI Guide
# Radiant Wave Portal AI Guide
## Mission
You are working on portal.Rhemawavetech.com.
You are working on portal.radiantwavetech.com.
Act as a senior engineer making complete, production-ready changes.
@@ -97,9 +97,9 @@ Use narrower commands when appropriate.
If validation fails:
* Diagnose the failure.
* Fix the issue.
* Run validation again.
- Diagnose the failure.
- Fix the issue.
- Run validation again.
Never finish with known failures.
@@ -109,19 +109,19 @@ Never finish with known failures.
Application:
portal.Rhemawavetech.com
portal.radiantwavetech.com
Stack:
* SvelteKit 2
* Svelte 5
* TypeScript
* Tailwind CSS v4
* Prisma 7
* PostgreSQL
* Better Auth
* Vitest
* Playwright
- SvelteKit 2
- Svelte 5
- TypeScript
- Tailwind CSS v4
- Prisma 7
- PostgreSQL
- Better Auth
- Vitest
- Playwright
Package manager:
@@ -266,9 +266,9 @@ AdminPage.svelte
Registry metadata exists for:
* columns
* searches
* relations
- columns
- searches
- relations
Forms are manually implemented per route.
@@ -309,8 +309,8 @@ Only load that file when modifying Svelte components or Svelte logic.
Testing uses:
* Vitest
* Playwright browser testing
- Vitest
- Playwright browser testing
For testing patterns, read:
@@ -338,10 +338,10 @@ Only load that file when working on API functionality.
Prettier rules:
* tabs
* single quotes
* no trailing commas
* 80 character print width
- tabs
- single quotes
- no trailing commas
- 80 character print width
Follow existing formatting.
@@ -391,17 +391,14 @@ Confirm:
2. Relevant tests/checks were run.
3. Failures were fixed.
4. The final response explains:
* what changed
* files modified
* verification performed
- what changed
- files modified
- verification performed
Do not stop at:
* "I found the issue"
* "Here is a plan"
* "The next step would be"
- "I found the issue"
- "Here is a plan"
- "The next step would be"
Finish the work.
+2 -2
View File
@@ -1,4 +1,4 @@
# portal.Rhemawavetech.com
# portal.radiantwavetech.com
SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth.
@@ -332,7 +332,7 @@ The `$effect` resets `dirty = false` on mount (when the record prop changes).
| Template ID | Variables | Subject (API-level) |
| ------------------------------ | --------------------------------------------------------------- | ------------------------------ |
| `automated-user-registration` | `firstName`, `tempPassword`, `verifyUrl`, `loginUrl` | "Your account is ready" |
| `license-key-delivery` | `firstName`, `license_key` | "Your Rhemawave License Key" |
| `license-key-delivery` | `firstName`, `license_key` | "Your Radiant Wave License Key" |
| `email-verification-email` | `target_email_address`, `verification_link` | "Verify your email address" |
Variables must be declared in the Resend template editor's Variables section. Undeclared variables render as literal `{{{varName}}}` text.
+1 -1
View File
@@ -1,4 +1,4 @@
# Rhema Wave Technologies - User Portal
# Radiant Wave Technologies - User Portal
A combination application that combines the main Radiant Wave API with a user-facing account management application,
and an admin-facing platform management application. An all-in-one management platform for Radiant Wave and Radiant Wave Technologies.
+184
View File
@@ -0,0 +1,184 @@
# 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`** still reports 142 `no-explicit-any` errors across 14 pre-existing spec files
(user/account, dashboard, license-keys, login/register/reset, etc.). These predate this overhaul
and are untouched here; fixing them is a separate cleanup.
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.
+2 -4
View File
@@ -89,8 +89,8 @@ POST:
Payload:
{
"license_key": "...",
"email_address": "..."
"license_key": "...",
"email_address": "..."
}
Requires API key.
@@ -153,7 +153,6 @@ Variables:
- verifyUrl
- loginUrl
license-key-delivery
Variables:
@@ -161,7 +160,6 @@ Variables:
- firstName
- license_key
email-verification-email
Variables:
+13 -12
View File
@@ -36,13 +36,13 @@ Always destructure $props() with defaults.
Preferred pattern:
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
isSuper = false
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
isSuper = false
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
isSuper?: boolean;
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
isSuper?: boolean;
} = $props();
Do not assume props exist.
@@ -66,7 +66,7 @@ Correct:
let name = $state('');
$effect(() => {
name = String(record.name ?? '');
name = String(record.name ?? '');
});
Use $effect whenever component state needs to follow changing props.
@@ -88,11 +88,11 @@ Example:
let dirty = $state(false);
$effect(() => {
dirty = false;
dirty = false;
});
$effect(() => {
name = String(record.name ?? '');
name = String(record.name ?? '');
});
This ensures a new record starts clean.
@@ -116,9 +116,10 @@ This can fail to update correctly.
Use:
<select onchange={(e) => frequency = e.currentTarget.value}>
<option value="1" selected={frequency === '1'}>
One
</option>
<option value="1" selected={frequency === '1'}>
One
</option>
</select>
Use selected on options.
+29 -29
View File
@@ -30,13 +30,13 @@ Tests are colocated with source files.
Client tests:
*.svelte.test.ts
*.svelte.spec.ts
_.svelte.test.ts
_.svelte.spec.ts
Server tests:
*.test.ts
*.spec.ts
_.test.ts
_.spec.ts
---
@@ -79,7 +79,7 @@ Node.
Always reset mocks:
beforeEach(() => {
vi.clearAllMocks();
vi.clearAllMocks();
});
---
@@ -109,15 +109,15 @@ Mock only the methods used.
Example:
vi.mock('$lib/db', () => ({
prisma: {
proclamation: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
prisma: {
proclamation: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
}));
Do not create massive unused mocks.
@@ -143,7 +143,7 @@ createLocals(mockUser() as any)
Specific roles:
createLocals(
mockUser({ roleId: 4 }) as any
mockUser({ roleId: 4 }) as any
)
---
@@ -172,15 +172,15 @@ Always use exact regex names.
Preferred:
page.getByRole(
'textbox',
{ name: /^Name$/ }
'textbox',
{ name: /^Name$/ }
)
Avoid:
page.getByRole(
'textbox',
{ name: 'Name' }
'textbox',
{ name: 'Name' }
)
because substring matching can create strict-mode failures.
@@ -192,15 +192,15 @@ because substring matching can create strict-mode failures.
Because selects use selected options instead of bind:value:
const element =
await page
.getByRole(
'combobox',
{ name: /^Frequency$/ }
)
.element();
await page
.getByRole(
'combobox',
{ name: /^Frequency$/ }
)
.element();
expect(
(element as HTMLSelectElement).value
(element as HTMLSelectElement).value
).toBe('2');
---
@@ -213,9 +213,9 @@ Use:
vi.mock('$lib/components/example.svelte', () => ({
default: ($anchor: Element) => {
const div = document.createElement('div');
$anchor.replaceWith(div);
}
const div = document.createElement('div');
$anchor.replaceWith(div);
}
}));
Imports using mocked modules must come after vi.mock().
+330
View File
@@ -0,0 +1,330 @@
# 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.
+2 -2
View File
@@ -1,11 +1,11 @@
{
"name": "portal.Rhemawavetech.com",
"name": "portal.radiantwavetech.com",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "portal.Rhemawavetech.com",
"name": "portal.radiantwavetech.com",
"version": "0.0.1",
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
+2 -2
View File
@@ -1,5 +1,5 @@
{
"name": "portal.Rhemawavetech.com",
"name": "portal.radiantwavetech.com",
"private": true,
"version": "0.0.1",
"type": "module",
@@ -67,4 +67,4 @@
"sveltekit-superforms": "^2.30.2",
"zod": "^4.4.3"
}
}
}
-756
View File
@@ -1,756 +0,0 @@
# RadiantWave Portal API v2 Schema
This document defines the **required** API surface for the RadiantWave kiosk
application and serves as the implementation contract for the portal
(`../portal`).
It supersedes `payloads.md` and `ApiUpdates.md`, which described a v1 API that
does not meet the application's requirements.
---
## Background and constraints
- The kiosk application (`this repo`) talks to
`https://portal.radiantwavetech.com/api/v1`.
- The **existing v1 routes must not change**: previously shipped kiosk binaries
depend on them. The v1 routes and their exact request/response shapes stay
frozen.
- To bring the API into compliance with this application's expectations we
introduce a **new, versioned API under `/api/v2`**, and migrate the kiosk
client to call `/api/v2` instead of `/api/v1`.
- The kiosk client currently uses a single base URL
(`src/network/api_client.h` → `API_BASE_URL`). All endpoint paths are
relative to that base, so migrating the base URL is the only client-side
change required.
Compliance means: every endpoint the kiosk parses must return exactly the
fields the kiosk expects, in the shapes documented below. Deviations break
provisioning, content sync, or rendering.
---
## Versioning strategy
| Route space | Status | Consumer |
|-------------|---------------------------|-------------------------------------------------------|
| `/api/v1/*` | Frozen, never modified | Old/shipped kiosk binaries, GoHighLevel automation |
| `/api/v2/*` | New (this document) | Current kiosk application (after base-URL migration) |
Rules:
1. Never change the behavior or response shape of an existing `/api/v1/*`
route. If a v1 fix is required, fix it in v2.
2. New capabilities ship under `/api/v2` only.
3. A consumer may pin to the major version it was built against. The kiosk
pins to `/api/v2`.
---
## General conventions (v2)
- Base URL: `https://portal.radiantwavetech.com/api/v2`.
- All routes require HTTPS (TLS 1.2+; TLS 1.3 recommended).
- Success responses are JSON with `content-type: application/json`.
- Error bodies use the shape `{"message": "..."}` for route-handler errors and
`{"error": "..."}` for the authentication layer (see Security).
- Any response code outside the documented success code is a failure; the
kiosk surfaces `message`/`error` text when present.
- API keys are sent as `Authorization: Bearer sk_<hex>` and are **required on
every v2 route** except the signed onboarding route (below).
- Credentials (license keys, emails) must **never** be passed in the query
string. v1 accepted `?email=&key=`; v2 must not.
- Request body sizes are capped (see Security); oversized bodies are rejected
with `413`.
---
## Canonical data model: solfeggio frequencies
The kiosk uses a fixed 9-preset frequency scheme. Frequency ids must be
**canonical** — they appear on proclamations and color palettes and must match
the kiosk's own `frequencyIdForHz()` mapping:
| Canonical id | Hz | Frequency (kiosk label) |
|--------------|------|------------------------------|
| 1 | 174 | Grounding / Relief (285 Hz is an alias of id 1) |
| 2 | 396 | Root |
| 3 | 417 | Sacral |
| 4 | 432 | Clarity / Wellbeing |
| 5 | 528 | Solar Plexus (fallback palette) |
| 6 | 639 | Heart |
| 7 | 741 | Throat |
| 8 | 852 | 3rd Eye |
| 9 | 963 | Crown |
**Compliance gap in v1:** the portal's `SolfeggioFrequency` table stores 7
rows whose ids do **not** match this scheme (id 1 = 936 Hz, id 5 = 528 Hz,
id 7 = 396 Hz, etc.). Because proclamations and color palettes are keyed by
frequency id, returning portal ids would make the kiosk look up the wrong
palette (e.g. a 396 Hz proclamation would render with the 741 Hz palette).
**v2 requirement:** every `solfeggioFrequencyId` emitted by v2 must be a
canonical id (1–9) whose `value` resolves through the kiosk map above.
Frequencies that have no canonical id (e.g. 936 Hz) must not be referenced by
any v2 proclamation or palette. See Implementation notes for the reconciling
strategy.
---
## Endpoints
### 1. `GET /api/v2/frequencies`
Lists the canonical solfeggio frequencies.
Request:
```
GET /api/v2/frequencies
Authorization: Bearer sk_<hex>
```
Response (200):
```json
[
{ "id": 1, "name": "Grounding / Relief", "value": 174 },
{ "id": 2, "name": "Root", "value": 396 },
{ "id": 3, "name": "Sacral", "value": 417 },
{ "id": 4, "name": "Clarity / Wellbeing", "value": 432 },
{ "id": 5, "name": "Solar Plexus", "value": 528 },
{ "id": 6, "name": "Heart", "value": 639 },
{ "id": 7, "name": "Throat", "value": 741 },
{ "id": 8, "name": "3rd Eye", "value": 852 },
{ "id": 9, "name": "Crown", "value": 963 }
]
```
Contract (kiosk `parseFrequenciesJson`):
- `id`: int, 1–9, **non-zero** (entries with id 0 are skipped).
- `name`: string.
- `value`: int, Hz. Should be a value the kiosk's `frequencyIdForHz()` map
recognizes (174, 285, 396, 417, 432, 528, 639, 741, 852, 963).
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of frequency objects | Success |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 2. `GET /api/v2/categories`
Lists all proclamation categories (identical shape to v1).
Request:
```
GET /api/v2/categories
Authorization: Bearer sk_<hex>
```
Response (200):
```json
[
{ "id": 4, "name": "Spiritual", "sortOrder": 0, "parentId": null },
{ "id": 5, "name": "General", "sortOrder": 0, "parentId": 4 }
]
```
Contract (kiosk `parseCategoriesJson`):
- `id`: int, **non-zero** (id 0 entries are skipped).
- `name`: string.
- `parentId`: int for children, **`null` for root** categories (kiosk maps
null → 0).
- `sortOrder`: int.
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of category objects | Success |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 3. `GET /api/v2/proclamations`
Returns the proclamation library. This is the kiosk's primary content sync.
Request (default: full library):
```
GET /api/v2/proclamations
Authorization: Bearer sk_<hex>
```
Optional entitlement filter (full records, see below):
```
GET /api/v2/proclamations
Authorization: Bearer sk_<hex>
X-License-Key: XXXX-XXXX-XXXX-XXXX
X-Email: user@example.com
```
> Header-based credentials replace the v1 `?email=&key=` query form. Do not
> read credentials from the URL.
Response (200) — every item is a **full proclamation record**:
```json
[
{
"id": "b40fdcb9-703c-420e-af32-a081964c6f08",
"name": "Peace & Relaxation",
"content": "<p>The text content, HTML formatted</p>",
"categoryId": 5,
"solfeggioFrequencyId": 5,
"subscriptionLevelId": 1,
"colorPalette": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"]
}
]
```
Contract (kiosk `parseProclamationsJson` + `parseProclamationPalettesJson`):
- `id`: string UUID, **required non-empty** (empty ids are skipped).
- `name`: string, used as the display title. Kiosk strips HTML from it.
- `content`: string, **HTML** (the kiosk strips tags and unescapes entities
before rendering). Plain text is also acceptable but HTML is the convention.
- `categoryId`: int, maps to a `/categories` id.
- `solfeggioFrequencyId`: int, **must be a canonical id (1–9)** — see the
canonical model above.
- `subscriptionLevelId`: int or `null`. The kiosk treats missing/null/≤0 as
level 1 (Basic).
- `colorPalette` (optional): array of exactly 7 `"#RRGGBB"` strings, indexed
color 0 (primary) through color 6 (tertiary). When present, the kiosk can
color a proclamation directly from this payload (first proclamation per
frequency wins for palette derivation). When absent, the kiosk falls back to
`/color-palettes`, then to its own 528 Hz seed.
With entitlement headers (`X-License-Key` + `X-Email`):
- Validate the key exists, is active, and belongs to the email's user.
- Return the **full proclamation records** the user may access (matching the
record shape above), **not** raw `UserProclamations` join-table rows.
(v1 returned join rows, which the kiosk parser cannot consume — this is the
primary v1 compliance defect this endpoint fixes.)
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of proclamation records | Success |
| 400 | `{"message": "not-authorized"}` | Entitlement headers supplied but key or email does not exist |
| 400 | `{"message": "this key doesn't appear to belong to you"}` | Key exists but owned by a different user |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 4. `POST /api/v2/license`
Validates a license key / email pair and returns the full subscription level.
Request:
```
POST /api/v2/license
Content-Type: application/json
Authorization: Bearer sk_<hex>
```
```json
{
"license_key": "XXXX-XXXX-XXXX-XXXX",
"email_address": "user@example.com"
}
```
Response (200):
```json
{
"key": "XXXX-XXXX-XXXX-XXXX",
"email_address": "user@example.com",
"subscription_level": {
"id": 2,
"name": "Advanced",
"description": "General + Specific"
}
}
```
`subscription_level` is **always an object** `{ id, name, description }`. If
the license's level name does not match any `SubscriptionLevel` row, fall back
to a structurally consistent object:
```json
{
"key": "XXXX-XXXX-XXXX-XXXX",
"email_address": "user@example.com",
"subscription_level": { "id": null, "name": "SomeCustomLevel", "description": null }
}
```
Contract (kiosk `parseLicenseJson`):
- `key`: string, **required non-empty**.
- `email_address`: string.
- `subscription_level.name`: string, **required** for a successful validation
(the kiosk persists id/name/description into its local `system_kv` table and
uses the level for proclamation gating and the Home footer).
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | success object | License valid and owned by the email |
| 400 | `{"message": "license_key is required"}` | Missing `license_key` |
| 400 | `{"message": "email_address is required"}` | Missing `email_address` |
| 400 | `{"message": "license key not found"}` | No key with that value |
| 400 | `{"message": "license key is not active"}` | Key exists but inactive |
| 400 | `{"message": "user does not exist"}` | No user with that email |
| 400 | `{"message": "key does not belong to specified user"}` | Key owned by a different user |
| 401 | `{"error": "..."}` | Missing/invalid API key |
> The kiosk client also tolerates the v1 string form and flat
> `subscription_level_id` / `subscription_level_description` fields. v2 should
> always send the object form; the fallbacks exist only for transitional
> compatibility.
---
### 5. `GET /api/v2/subscription-levels`
Lists the subscription levels.
Request:
```
GET /api/v2/subscription-levels
Authorization: Bearer sk_<hex>
```
Response (200):
```json
[
{ "id": 1, "name": "Basic", "description": "General only" },
{ "id": 2, "name": "Advanced", "description": "General + Specific" },
{ "id": 3, "name": "Elite", "description": "General + Specific + Precise" }
]
```
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of `{id, name, description}` | Success |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 6. `GET /api/v2/color-palettes`
Exposes the 7-color palette for every canonical frequency so the portal admin
can update colors without a kiosk code release.
Request:
```
GET /api/v2/color-palettes
Authorization: Bearer sk_<hex>
```
Response (200) — one entry per canonical frequency (1–9):
```json
[
{
"solfeggioFrequencyId": 1,
"colors": ["#8B6914", "#B8860B", "#DAA520", "#E8B64C", "#FFD700", "#FFE9A8", "#FFF4CC"]
},
{
"solfeggioFrequencyId": 5,
"colors": ["#32CD32", "#3CB371", "#90EE90", "#98FB98", "#ADFFA9", "#C8FFD2", "#F0FFF0"]
}
]
```
Contract (kiosk `parseColorPalettesJson`):
- `solfeggioFrequencyId`: int, **non-zero**, canonical id.
- `colors`: array of `"#RRGGBB"` (or `"RRGGBB"`) strings, exactly 7.
Kiosk fallback: `db::getPaletteForFrequencyId()` falls back to the 528 Hz
palette for any frequency id with no rows; the startup seed backfills missing
ids with `INSERT OR IGNORE`, so portal values win after a successful sync.
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | array of `{solfeggioFrequencyId, colors}` | Success |
| 401 | `{"error": "..."}` | Missing/invalid API key |
---
### 7. `POST /api/v2/automations/create/account`
Creates (or reuses) a user account and issues a brand-new active license key.
The key is **not returned** — it is emailed to the user. This is the only v2
route that does not require a bearer API key; instead it requires an
**HMAC-SHA256 signature** (see Security). It is used for first-run provisioning
and by the GoHighLevel automation.
Request:
```
POST /api/v2/automations/create/account
Content-Type: application/json
X-Timestamp: <unix seconds>
X-Nonce: <random uuid>
X-Signature: <hex HMAC-SHA256 of canonical request with the onboarding secret>
```
```json
{
"email": "user@example.com",
"name": "Optional Display Name",
"subscription_level": "Basic"
}
```
- `email` (required, string).
- `name` (optional, string; defaults to the local part of `email`).
- `subscription_level` (required, string) — one of `Basic`, `Advanced`,
`Elite` (must match a `SubscriptionLevel.name`).
Response (200):
```json
{
"success": true,
"message": "Account and license key created",
"user_id": "uuid"
}
```
Errors:
| Code | Body | Meaning |
|------|------|---------|
| 200 | success object | Account (+ license key) created |
| 400 | `{"message": "email is required"}` | Missing/empty `email` |
| 400 | `{"message": "subscription_level is required"}` | Missing/empty `subscription_level` |
| 400 | `{"message": "subscription_level \"<name>\" not found"}` | Unknown level name |
| 401 | `{"error": "invalid signature"}` | Bad/expired/replayed HMAC signature |
| 429 | `{"message": "rate limit exceeded"}` | Too many requests from this IP/email |
| 500 | `{"message": "Failed to create account"}` | User creation failed |
| 500 | `{"message": "Failed to find or create user"}` | User could not be located |
Behavior (unchanged from v1):
- If the email already belongs to a user, no new user is created — only a new
license key for that user.
- A new user receives a temporary-password/verify email and a license-key
email. Sending up to two emails, so this endpoint must be rate-limited
aggressively.
---
## Compliance contract (kiosk → v2)
| Kiosk consumer | v2 endpoint | Required fields / shape |
|----------------|-------------|-------------------------|
| `sync::syncProclamationsFromApi` (Home + ProclamationOptions sync) | `GET /proclamations`, `GET /categories` | Full proclamation records; `id`, `name`, `content`, `categoryId`, `solfeggioFrequencyId` (canonical), `subscriptionLevelId` |
| `license::validateStoredLicense` (License Key provisioning page) | `POST /license` | `subscription_level` as `{id, name, description}` object |
| `api::fetchColorPalettes` / `sync::syncColorPalettesFromApi` (readiness, to be wired) | `GET /color-palettes` | `[{solfeggioFrequencyId, colors[7]}]` |
| `sync::syncProclamationPalettesFromApi` (readiness, to be wired) | `GET /proclamations` | Embedded `colorPalette` array per proclamation |
| `api::fetchFrequencies` (readiness) | `GET /frequencies` | Canonical id 1–9 + `value` in Hz |
| Future level-picker UI | `GET /subscription-levels` | `{id, name, description}` |
| Provisioning / GHL automation | `POST /automations/create/account` | `email`, `subscription_level`, optional `name` |
---
## Implementation instructions (portal, `../portal`)
### Route layout
Create the v2 route tree alongside the frozen v1 tree:
```
src/routes/api/v2/
frequencies/+server.ts
categories/+server.ts
proclamations/+server.ts
license/+server.ts
subscription-levels/+server.ts
color-palettes/+server.ts
automations/create/account/+server.ts
```
### Auth gate (`src/hooks.server.ts`)
Extend the existing handler so v2 auth is **always enforced** (do not honor
`API_KEYS_ACTIVE` for v2):
```ts
const isV2 = event.url.pathname.startsWith('/api/v2/');
const isSignedOnboarding =
event.url.pathname.startsWith('/api/v2/automations/');
if (isV2 && !isSignedOnboarding) {
// validateApiKey() ALWAYS runs for v2 — no env-var bypass.
const authHeader = event.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'API key required' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
const record = await validateApiKey(authHeader.slice(7));
if (!record) {
return new Response(JSON.stringify({ error: 'Invalid or expired API key' }), {
status: 401,
headers: { 'content-type': 'application/json' }
});
}
return resolve(event);
}
```
Keep the existing v1 branch untouched.
### Shared serializer (`src/lib/server/v2-serializers.ts`)
Add one module that maps DB rows to the v2 wire format. The critical piece is
frequency-id reconciliation:
```ts
// Canonical id <- Hz (kiosk frequencyIdForHz map).
const CANONICAL_ID_FOR_HZ: Record<number, number> = {
174: 1, 285: 1, 396: 2, 417: 3, 432: 4,
528: 5, 639: 6, 741: 7, 852: 8, 963: 9
};
function canonicalIdForFrequency(freq: { id: number; value: number; name: string }): number | null {
const id = CANONICAL_ID_FOR_HZ[freq.value];
return id ?? null; // e.g. 936 Hz has no canonical id -> excluded from v2
}
export function serializeProclamation(p: Proclamation & { solfeggioFrequency: { value: number } }) {
const solfeggioFrequencyId = CANONICAL_ID_FOR_HZ[p.solfeggioFrequency.value];
if (!solfeggioFrequencyId) return null; // drop proclamations with no canonical frequency
return {
id: p.id,
name: p.name,
content: p.content,
categoryId: p.categoryId,
solfeggioFrequencyId,
subscriptionLevelId: p.subscriptionLevelId,
// optional; read from the palette source below
colorPalette: paletteFor(p.solfeggioFrequency.value) // 7x "#RRGGBB" or omit
};
}
```
Key points:
- Derive the emitted `solfeggioFrequencyId` from the joined frequency's
**Hz value**, never from the DB id. This keeps v1 rows (and their ids)
untouched while v2 emits canonical ids.
- Proclamations whose frequency has no canonical id (e.g. 936 Hz) are excluded
from v2 responses (or re-assigned a canonical frequency in data).
- Add the missing frequencies to the DB so content can be authored for them:
`174, 285, 432, 963` (by `value`). Do not delete or renumber the existing
rows that v1 depends on.
### Color palettes
Add a portal-editable palette source keyed by canonical frequency, for example
a `colorPalette` JSON column on `SolfeggioFrequency` or a
`ColorPalette`/`ColorPaletteColor` table:
```prisma
model ColorPalette {
solfeggioFrequencyId Int @id
colors Json // ["#RRGGBB", ... x7]
}
```
- Seed all 9 canonical palettes to match the kiosk's current seed
(`src/db/database_manager.cpp` → `seedColorPalettes()`).
- `GET /color-palettes` returns one entry per canonical id.
- `GET /proclamations` embeds the palette for the proclamation's canonical
frequency.
### License endpoint
In `license/+server.ts`, resolve the level object like v1 change #1 suggested:
```ts
const level = await prisma.subscriptionLevel.findFirst({
where: { name: storedKey.level }
});
subscription_level = level
? { id: level.id, name: level.name, description: level.description }
: { id: null, name: storedKey.level, description: null };
```
### Proclamations entitlement filter
When `X-License-Key` / `X-Email` headers are present, validate them exactly as
v1 does, but return **full proclamation records** (the user's entitled set via
`UserProclamations` + `Proclamation` include), never join-table rows.
### Tests
- Unit-test the serializers with fixtures matching the kiosk's offline tests
in `tests/test_api_client.cpp` (string vs object `subscription_level`,
`subscriptionLevelId: null`, `parentId: null`, 7-color palettes, canonical
frequency mapping incl. 936 → dropped).
- Live smoke tests against `/api/v2` mirroring the existing v1 live tests.
---
## Security: restricting v2 to the kiosk application
These endpoints should be reachable only from enrolled kiosk devices. v1's
auth could be disabled with `API_KEYS_ACTIVE=false`; v2 must not offer that
bypass. Suggestions below are ordered by impact.
### 1. Mandatory bearer-key auth on every content route
- Every v2 route except the signed onboarding route requires
`Authorization: Bearer sk_<hex>`.
- Enforce it in `hooks.server.ts` unconditionally (no env toggle).
- Keys are generated with `generateApiKey()`, stored **SHA-256 hashed**, and
only the `sk_xxxxxxxxxx` prefix is ever displayed. Never log or return the
plaintext key.
- Use constant-time comparison / lookup by hash (already the case via
`validateApiKey`).
### 2. Per-device credentials (enroll each kiosk)
One shared API key is a single point of failure. Instead:
- Add a `Device` record (id, serial, model, owner, enrolledAt, apiKeyId) and
issue **one unique key per kiosk** at first boot (or at manufacturing).
- Bind the key to the device: include the device id in a custom header
(`X-Device-Id`) and verify the key belongs to that device.
- A compromised key is then revoked per-device without a global rotation, and
the portal can tell which device made each request.
### 3. Least privilege / scoped keys
- Kiosk keys are read-only for content (`/frequencies`, `/categories`,
`/proclamations`, `/color-palettes`, `/subscription-levels`) and may POST to
`/license`.
- Reject any other verb/route combination with 403. Admin and data-mutation
keys are issued separately to portal staff only.
### 4. Rate limiting per key and per IP
- Content GETs: ~60/min per key/IP.
- `/license`: ~10/min per key/IP.
- `/automations/create/account`: ~5/min per IP and ~3/day per email.
- Return `429` with `Retry-After`; the kiosk client should back off and retry.
- Slippery handling for repeated failures (rate-limit on failures too).
### 5. Replay protection / request signing
- The bearer key alone is replayable if captured. Add to the high-risk routes
(and the onboarding route) an HMAC-SHA256 signature over the canonical
request (method, path, `X-Timestamp`, `X-Nonce`, raw body) using a secret
known only to the device and the server.
- Reject when `|now − X-Timestamp| > 300s` or when a nonce has been seen
before. This defeats capture-and-replay attacks over the network.
- Implement the same scheme for the onboarding route using a shared secret
configured on the portal and the GoHighLevel automation (replacing the
current entirely unauthenticated `/automations/*` exemption).
### 6. Never put credentials in URLs
- v1 accepted license keys and emails in the query string, which land in
access logs, proxies, and referrers. v2 reads credentials from the
`Authorization` header and JSON body only.
- Redact `Authorization` values, license keys, and emails in application and
proxy logs.
### 7. Client fingerprinting and bot rejection
- The kiosk sends `User-Agent: RadiantWave-Kiosk/0.1`. Require it (optionally
with a signed `X-Client-Id`) and return `403` for generic browsers, `curl`,
and headless scanners.
- Reject request bodies that are not `application/json` where JSON is
expected; enforce request-size limits (reject with `413`).
- At the proxy layer: require TLS 1.2+, prefer 1.3, set HSTS; consider
restricting v2 to expected deployment geographies and blocking TOR/exit
nodes and known hosting/bot ASNs.
### 8. Input validation and uniform errors
- Validate each body against a strict schema: email ≤ 254 chars and a valid
shape, `license_key` must match `^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$`,
`subscription_level` must equal a known level name, `name` ≤ 120 chars.
- Reject embedded HTML/script in string fields.
- Return uniform `{"message": ...}` bodies; never leak stack traces or SQL
errors. Avoid verbose error differences that enable credential enumeration
(see below).
### 9. License / onboarding abuse resistance
- `/license` success/failure is enumerable by design (a fixed key format). At
minimum rate-limit aggressively. Consider returning a generic
`{"message": "invalid credentials"}` (possibly plus a structured `code`
field for the kiosk) so attackers cannot distinguish "user exists" from
"key exists". Keep the specific messages only behind authenticated admin
tooling if they are needed for support.
- `/automations/create/account` mints real license keys and sends email, so
protect it hardest: HMAC signature (above), per-IP and per-email rate
limits, an email-domain/typo check, and a honeypot field. Never expose it
without signature as an unauthenticated "create account" funnel.
### 10. Monitoring, logging, alerting
- Log every v2 request as structured data: timestamp, endpoint, method, status,
key prefix, `X-Device-Id`, IP, latency. Never log full keys, emails, or
bodies.
- Alert on: error-rate spikes, repeated 401s, unexpected `/license` volume,
signup bursts, single-device request floods, and revoked-key usage.
- Automatically suspend a device that shows abusive patterns; require
re-enrollment.
### 11. Key lifecycle
- Rotate keys on a schedule; support the existing `rotateApiKey` 48-hour grace
and emergency revocation per device.
- Auto-expire keys that have not authenticated in N days.
- Consider an authenticated `/api/v2/auth/refresh` (or device re-enrollment)
so a kiosk can obtain a fresh key, turning a leaked key into a self-healing
problem.
---
## Rollout checklist
1. Implement the v2 routes and serializers; add unit + live smoke tests.
2. Ship v2 alongside v1 (both live; v1 frozen).
3. Flip the kiosk base URL: `src/network/api_client.h` →
`https://portal.radiantwavetech.com/api/v2` (already done by this change).
4. Run the kiosk live tests with `RW_LIVE_API_KEY` set:
- `./build/test_api_client`
- `./build/test_license_service`
5. Update portal-admin color palettes and verify a fresh kiosk sync picks them
up (now wired via `/color-palettes`).
6. Keep `/api/v1` online until all deployed kiosks are on the v2 build;
monitor v1 traffic and sunset it only after the fleet is migrated.
@@ -0,0 +1,115 @@
-- Flatten ProclamationCategory hierarchy into a flat list and introduce
-- ProclamationGroup (formerly child sub-categories).
--
-- This migration is intentionally NON-DESTRUCTIVE:
-- * ProclamationGroup / Proclamation.groupId are added additively.
-- * Former child categories are ARCHIVED to ProclamationCategoryLegacy
-- before the hierarchy (parentId) is removed, so no category data is lost.
-- * Subscription-level resources (SubscriptionLevel,
-- ProclamationCategorySubscriptionLevel, Proclamation.subscriptionLevelId,
-- LicenseKey.level) are LEFT IN PLACE as unmanaged legacy data. They are no
-- longer modeled by the Prisma schema and are ignored by the application,
-- but their data is preserved and no DROP is attempted on tables the deploy
-- role may not own.
-- CreateTable
CREATE TABLE "ProclamationGroup" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "ProclamationGroup_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ProclamationGroup_name_key" ON "ProclamationGroup"("name");
-- Seed groups from every distinct former sub-category name
INSERT INTO "ProclamationGroup" ("name", "sortOrder")
SELECT "name",
ROW_NUMBER() OVER (ORDER BY "name") - 1
FROM (
SELECT DISTINCT "name" FROM "ProclamationCategory" WHERE "parentId" IS NOT NULL
) AS d;
-- AddColumn (nullable during backfill)
ALTER TABLE "Proclamation" ADD COLUMN "groupId" INTEGER;
-- Backfill: former child categories become the proclamation's group; the
-- parent becomes its flat category.
UPDATE "Proclamation" AS p
SET "groupId" = g."id",
"categoryId" = c."parentId"
FROM "ProclamationCategory" AS c
JOIN "ProclamationGroup" AS g ON g."name" = c."name"
WHERE p."categoryId" = c."id" AND c."parentId" IS NOT NULL;
-- Backfill: proclamations that had no group (e.g. 'Custom (Client Specific)')
-- default to the General group, if it exists.
UPDATE "Proclamation"
SET "groupId" = (SELECT "id" FROM "ProclamationGroup" WHERE "name" = 'General')
WHERE "groupId" IS NULL;
-- Safety net: any proclamation still unmapped takes the first group.
UPDATE "Proclamation"
SET "groupId" = (SELECT "id" FROM "ProclamationGroup" ORDER BY "sortOrder" LIMIT 1)
WHERE "groupId" IS NULL;
ALTER TABLE "Proclamation" ALTER COLUMN "groupId" SET NOT NULL;
-- AddForeignKey
ALTER TABLE "Proclamation" ADD CONSTRAINT "Proclamation_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "ProclamationGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Archive former child categories before removing them, so their data is
-- preserved and recoverable. The legacy table also records each child's
-- parent for audit/recovery.
CREATE TABLE "ProclamationCategoryLegacy" (
"id" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"parentId" INTEGER,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "ProclamationCategoryLegacy_pkey" PRIMARY KEY ("id")
);
INSERT INTO "ProclamationCategoryLegacy" ("id", "name", "parentId", "sortOrder")
SELECT "id", "name", "parentId", "sortOrder"
FROM "ProclamationCategory"
WHERE "parentId" IS NOT NULL;
-- Archive category->subscription mappings for the child categories being
-- removed, so the cascade delete below does not destroy this (deprecated)
-- mapping data. Requires read access to the join table, but never ownership.
CREATE TABLE "ProclamationCategorySubscriptionLevelLegacy" (
"categoryId" INTEGER NOT NULL,
"subscriptionLevelId" INTEGER NOT NULL,
CONSTRAINT "ProclamationCategorySubscriptionLevelLegacy_pkey" PRIMARY KEY ("categoryId", "subscriptionLevelId")
);
INSERT INTO "ProclamationCategorySubscriptionLevelLegacy" ("categoryId", "subscriptionLevelId")
SELECT j."categoryId", j."subscriptionLevelId"
FROM "ProclamationCategorySubscriptionLevel" AS j
JOIN "ProclamationCategory" AS c ON c."id" = j."categoryId" AND c."parentId" IS NOT NULL;
-- Remove former child categories (their data is archived above; the
-- subscription-level join rows for them are archived above and the
-- SubscriptionLevel table itself is left untouched).
DELETE FROM "ProclamationCategory" WHERE "parentId" IS NOT NULL;
-- Drop self-relation
ALTER TABLE "ProclamationCategory" DROP CONSTRAINT "ProclamationCategory_parentId_fkey";
-- DropIndex
DROP INDEX "ProclamationCategory_parentId_name_key";
-- AlterTable
ALTER TABLE "ProclamationCategory" DROP COLUMN "parentId";
-- CreateIndex
CREATE UNIQUE INDEX "ProclamationCategory_name_key" ON "ProclamationCategory"("name");
-- Subscription-level resources are intentionally left in place (unmanaged).
-- No DROP TABLE / DROP COLUMN / DROP CONSTRAINT is performed here, so this
-- migration never requires ownership of pre-existing subscription tables and
-- never destroys subscription data.
@@ -0,0 +1,28 @@
-- DropForeignKey
ALTER TABLE "LicenseKey" DROP CONSTRAINT "LicenseKey_userId_fkey";
-- DropIndex
DROP INDEX "Profile_userId_key";
-- AlterTable
ALTER TABLE "LicenseKey" ADD COLUMN "email" TEXT,
ALTER COLUMN "userId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Profile" ADD COLUMN "email" TEXT,
ALTER COLUMN "userId" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "LicenseKey_userId_idx" ON "LicenseKey"("userId");
-- CreateIndex
CREATE INDEX "LicenseKey_email_idx" ON "LicenseKey"("email");
-- CreateIndex
CREATE INDEX "Profile_userId_idx" ON "Profile"("userId");
-- CreateIndex
CREATE INDEX "Profile_email_idx" ON "Profile"("email");
-- AddForeignKey
ALTER TABLE "LicenseKey" ADD CONSTRAINT "LicenseKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+62 -45
View File
@@ -25,7 +25,7 @@ model User {
role UserRole? @relation(fields: [roleId], references: [id])
sessions Session[]
accounts Account[]
profile Profile?
profiles Profile[]
licenseKeys LicenseKey[]
userProclamations UserProclamations[]
@@ -37,28 +37,32 @@ model User {
// a bunch of time trying to figure out how to customize the default Better-Auth model.
model Profile {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
email String?
firstName String
lastName String
phoneNumber String
ghlContactId String? @map("ghl_contact_id")
addresses Address[]
@@index([userId])
@@index([email])
}
model Address {
id String @id @default(uuid())
profileId String
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
street String
city String
state String
postalCode String
country String
type String @default("Mailing")
isDefaultMailing Boolean @default(false)
isDefaultBilling Boolean @default(false)
isDefaultShipping Boolean @default(false)
id String @id @default(uuid())
profileId String
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
street String
city String
state String
postalCode String
country String
type String @default("Mailing")
isDefaultMailing Boolean @default(false)
isDefaultBilling Boolean @default(false)
isDefaultShipping Boolean @default(false)
}
model Session {
@@ -123,9 +127,16 @@ model Proclamation {
solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id])
categoryId Int
category ProclamationCategory @relation(fields: [categoryId], references: [id])
groupId Int
group ProclamationGroup @relation(fields: [groupId], references: [id])
userProclamations UserProclamations[]
subscriptionLevelId Int // Keeping old FK for migration stability
subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id])
}
model ProclamationGroup {
id Int @id @default(autoincrement())
name String @unique
sortOrder Int @default(0)
proclamations Proclamation[]
}
model UserProclamations {
@@ -142,40 +153,54 @@ model UserProclamations {
model SolfeggioFrequency {
id Int @id @default(autoincrement())
name String @unique
value Int @unique
value Int @unique
proclamations Proclamation[]
}
model ProclamationCategory {
id Int @id @default(autoincrement())
name String
sortOrder Int @default(0)
parentId Int?
parent ProclamationCategory? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children ProclamationCategory[] @relation("CategoryHierarchy")
proclamations Proclamation[]
subscriptionLevels ProclamationCategorySubscriptionLevel[]
@@unique([parentId, name])
id Int @id @default(autoincrement())
name String @unique
sortOrder Int @default(0)
proclamations Proclamation[]
}
// Join table for ProclamationCategory -> SubscriptionLevel defaults
model ProclamationCategorySubscriptionLevel {
categoryId Int
category ProclamationCategory @relation(fields: [categoryId], references: [id], onDelete: Cascade)
// Archive of the former child sub-categories removed when the
// ProclamationCategory hierarchy was flattened. Kept for recovery/audit.
// `@@ignore` keeps it out of the generated client while Prisma remains aware
// of the table so `migrate dev` does not report it as an untracked table.
model ProclamationCategoryLegacy {
id Int @id
name String
parentId Int?
sortOrder Int @default(0)
@@map("ProclamationCategoryLegacy")
@@ignore
}
// Archive of the category->subscription-level mappings for the child
// categories removed when the category hierarchy was flattened. Kept with
// `@@ignore` so Prisma remains aware of the table but it is excluded from the
// generated client and Prisma's own migrations.
model ProclamationCategorySubscriptionLevelLegacy {
categoryId Int
subscriptionLevelId Int
subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id], onDelete: Cascade)
@@id([categoryId, subscriptionLevelId])
@@map("ProclamationCategorySubscriptionLevelLegacy")
@@ignore
}
model LicenseKey {
id String @id @default(uuid())
key String @unique
active Boolean
level String
userId String
user User @relation(fields: [userId], references: [id])
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
email String?
@@index([userId])
@@index([email])
}
model ApiKey {
@@ -187,14 +212,6 @@ model ApiKey {
revokedAt DateTime?
}
model SubscriptionLevel {
id Int @id @default(autoincrement())
name String
description String?
proclamations Proclamation[]
categoryLevels ProclamationCategorySubscriptionLevel[]
}
// Portal-editable 7-color palette per canonical solfeggio frequency id (1-9).
// `solfeggioFrequencyId` is the canonical id the kiosk uses (see
// v2-serializers.ts), not the DB autoincrement id of `SolfeggioFrequency`.
@@ -203,4 +220,4 @@ model ColorPalette {
colors Json // ["#RRGGBB", ... x7]
@@map("ColorPalette")
}
}
+26 -43
View File
@@ -72,43 +72,26 @@ const colorPalettes: { solfeggioFrequencyId: number; colors: string[] }[] = [
}
];
const subscriptionLevels = [
{ id: 1, name: 'Basic', description: 'General only' },
{ id: 2, name: 'Advanced', description: 'General + Specific' },
{ id: 3, name: 'Elite', description: 'General + Specific + Precise' }
const proclamationGroups = [
{ name: 'General', sortOrder: 0 },
{ name: 'Specific', sortOrder: 1 },
{ name: 'Precise', sortOrder: 2 },
{ name: 'Prayers', sortOrder: 3 },
{ name: 'Scripture', sortOrder: 4 }
];
// Flat category list (formerly hierarchical parents).
const proclamationCategories = [
{ id: 4, name: 'Spiritual', parentId: null },
{ id: 5, name: 'General', parentId: 4 },
{ id: 9, name: 'Specific', parentId: 4 },
{ id: 40, name: 'Precise', parentId: 4 },
{ id: 2, name: 'Physical', parentId: null },
{ id: 3, name: 'General', parentId: 2 },
{ id: 10, name: 'Specific', parentId: 2 },
{ id: 32, name: 'Precise', parentId: 2 },
{ id: 16, name: 'Mental', parentId: null },
{ id: 30, name: 'General', parentId: 16 },
{ id: 31, name: 'Specific', parentId: 16 },
{ id: 38, name: 'Precise', parentId: 16 },
{ id: 17, name: 'Custom (Client Specific)', parentId: null },
{ id: 19, name: 'Financial', parentId: null },
{ id: 28, name: 'General', parentId: 19 },
{ id: 29, name: 'Specific', parentId: 19 },
{ id: 37, name: 'Precise', parentId: 19 },
{ id: 20, name: 'Emotional', parentId: null },
{ id: 24, name: 'General', parentId: 20 },
{ id: 26, name: 'Specific', parentId: 20 },
{ id: 45, name: 'Precise', parentId: 20 },
{ id: 21, name: 'Social', parentId: null },
{ id: 22, name: 'General', parentId: 21 },
{ id: 23, name: 'Specific', parentId: 21 },
{ id: 39, name: 'Precise', parentId: 21 },
{ id: 41, name: 'Biblical', parentId: null },
{ id: 42, name: 'Personal Growth & Development', parentId: null },
{ id: 43, name: 'Prayers', parentId: 41 },
{ id: 44, name: 'Scripture', parentId: 41 },
{ id: 46, name: 'Sports', parentId: null }
{ id: 4, name: 'Spiritual' },
{ id: 2, name: 'Physical' },
{ id: 16, name: 'Mental' },
{ id: 17, name: 'Custom (Client Specific)' },
{ id: 19, name: 'Financial' },
{ id: 20, name: 'Emotional' },
{ id: 21, name: 'Social' },
{ id: 41, name: 'Biblical' },
{ id: 42, name: 'Personal Growth & Development' },
{ id: 46, name: 'Sports' }
];
async function main() {
@@ -130,6 +113,15 @@ async function main() {
});
}
console.log('Seeding ProclamationGroup...');
for (const group of proclamationGroups) {
await prisma.proclamationGroup.upsert({
where: { name: group.name },
create: group,
update: group
});
}
console.log('Seeding ProclamationCategory...');
for (const cat of proclamationCategories) {
await prisma.proclamationCategory.upsert({
@@ -139,15 +131,6 @@ async function main() {
});
}
console.log('Seeding SubscriptionLevel...');
for (const level of subscriptionLevels) {
await prisma.subscriptionLevel.upsert({
where: { id: level.id },
create: level,
update: level
});
}
console.log('Seeding ColorPalette...');
for (const palette of colorPalettes) {
await prisma.colorPalette.upsert({
+2 -15
View File
@@ -3,23 +3,10 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
(function () {
const theme = localStorage.getItem('theme');
if (
theme === 'dark' ||
(!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
})();
</script>
%sveltekit.head%
</head>
<body class="m-0" data-sveltekit-preload-data="hover">
<div class="flex h-dvh w-dvw flex-col">%sveltekit.body%</div>
<body data-sveltekit-preload-data="hover">
<div>%sveltekit.body%</div>
</body>
</html>
+1 -1
View File
@@ -51,6 +51,6 @@ export const auth = betterAuth({
window: 60,
max: 100
},
trustedOrigins: ['http://localhost:5173', 'https://portal.rhemawavetech.com'],
trustedOrigins: ['http://localhost:5173', 'https://portal.radiantwavetech.com'],
plugins: [sveltekitCookies(getRequestEvent)]
});
+215
View File
@@ -0,0 +1,215 @@
<script lang="ts">
export type DataTableColumn = {
label: string;
value: (record: Record<string, unknown>) => unknown;
};
let {
title = '',
columns = [] as DataTableColumn[],
records = [] as Record<string, unknown>[],
newLabel = 'New',
searchPlaceholder = 'Search...',
copyableFields = [] as string[],
draggable = false,
onRowClick,
onNew,
onReorder
}: {
title?: string;
columns?: DataTableColumn[];
records?: Record<string, unknown>[];
newLabel?: string;
searchPlaceholder?: string;
copyableFields?: string[];
draggable?: boolean;
onRowClick?: (record: Record<string, unknown>) => void;
onNew?: () => void;
onReorder?: (ids: (string | number)[]) => void;
} = $props();
let search = $state('');
const filteredRecords = $derived(
search
? records.filter((record) =>
columns.some((column) =>
String(column.value(record) ?? '')
.toLowerCase()
.includes(search.toLowerCase())
)
)
: records
);
let dragOverride = $state<Record<string, unknown>[] | null>(null);
let dragIndex = $state<number | null>(null);
let hoverIndex = $state<number | null>(null);
const rows = $derived(dragOverride ?? filteredRecords);
function rowKey(record: Record<string, unknown>, index: number): string {
return String(record.id ?? record.key ?? index);
}
function copyValue(value: unknown) {
navigator.clipboard.writeText(String(value ?? ''));
}
function handleDragStart(e: DragEvent, i: number) {
dragOverride = filteredRecords;
dragIndex = i;
hoverIndex = i;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', '');
}
function handleDragOver(e: DragEvent, i: number) {
if (dragIndex === null) return;
e.preventDefault();
if (i === hoverIndex) return;
e.dataTransfer!.dropEffect = 'move';
const reordered = rows.slice();
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(i, 0, moved);
dragOverride = reordered;
dragIndex = i;
hoverIndex = i;
}
async function handleDrop() {
if (dragIndex === null) return;
const ids = rows.map((r) => r.id as string | number);
await onReorder?.(ids);
dragIndex = null;
hoverIndex = null;
}
function handleDragEnd() {
dragOverride = null;
dragIndex = null;
hoverIndex = 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">{title}</h2>
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
<input
type="search"
bind:value={search}
placeholder={searchPlaceholder}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none sm:w-64"
/>
{#if onNew}
<button
type="button"
onclick={onNew}
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"
>
<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"
aria-hidden="true"><path d="M12 5v14" /><path d="M5 12h14" /></svg
>
{newLabel}
</button>
{/if}
</div>
</div>
<div class="w-full overflow-x-auto rounded-lg border border-slate-200 bg-white shadow-sm">
<table class="w-full min-w-max text-left text-sm text-slate-700">
<thead>
<tr
class="border-b border-slate-200 bg-slate-100 text-xs font-semibold tracking-wide text-slate-500"
>
{#if draggable}
<th scope="col" class="w-10 px-4 py-3"></th>
{/if}
{#each columns as column (column.label)}
<th scope="col" class="px-4 py-3">{column.label}</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as record, i (rowKey(record, i))}
<tr
class="transition {i % 2 === 0 ? 'bg-white' : 'bg-slate-50'} {onRowClick
? 'cursor-pointer hover:bg-indigo-50'
: ''}"
{draggable}
ondragstart={(e) => handleDragStart(e, i)}
ondragover={(e) => handleDragOver(e, i)}
ondrop={handleDrop}
ondragend={handleDragEnd}
onclick={() => onRowClick?.(record)}
>
{#if draggable}
<td class="px-4 py-3" onclick={(e) => e.stopPropagation()} aria-hidden="true">
<span
class="cursor-grab text-slate-400 select-none active:cursor-grabbing"
title="Drag to reorder">⠿</span
>
</td>
{/if}
{#each columns as column (column.label)}
<td class="px-4 py-3 whitespace-nowrap">
{#if copyableFields.includes(column.label) && column.value(record) != null}
<span>
{String(column.value(record))}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
copyValue(column.value(record));
}}
title="Copy to clipboard"
class="ml-1 inline-flex text-slate-400 transition hover:text-indigo-600"
>
<svg
viewBox="0 0 16 16"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect x="3.5" y="2.5" width="11" height="11" rx="1" />
<path d="M5.5 2.5V1.5a1 1 0 011-1h3a1 1 0 011 1v1" />
</svg>
</button>
</span>
{:else}
{String(column.value(record) ?? '')}
{/if}
</td>
{/each}
</tr>
{:else}
<tr>
<td
colspan={columns.length + (draggable ? 1 : 0)}
class="bg-white px-4 py-10 text-center text-slate-500"
>
No results found.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
+17 -12
View File
@@ -17,8 +17,8 @@
deleteAction?: string;
} = $props();
function handleBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose?.();
function handleBackdrop() {
onClose?.();
}
function handleKeydown(e: KeyboardEvent) {
@@ -44,24 +44,27 @@
{#if open}
<div
role="presentation"
class="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 pt-12"
onclick={handleBackdrop}
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onkeydown={handleKeydown}
role="presentation"
>
<div class="absolute inset-0" onclick={handleBackdrop} aria-hidden="true"></div>
<div
class="my-8 w-full max-w-2xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900"
role="dialog"
aria-modal="true"
aria-label={title}
class="relative z-10 flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl"
>
<div
class="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h2>
<h2 class="text-lg font-bold text-slate-900">{title}</h2>
<div class="flex items-center gap-2">
{#if canDelete && recordId}
<button
type="button"
onclick={confirmDelete}
class="px-3 py-1 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
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"
>
Delete
</button>
@@ -69,12 +72,14 @@
<button
type="button"
onclick={onClose}
class="px-2 py-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>&times;</button
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"
>
&times;
</button>
</div>
</div>
<div class="px-6 py-4">
<div class="overflow-y-auto px-6 py-5">
{@render children()}
</div>
</div>
-248
View File
@@ -1,248 +0,0 @@
<script lang="ts">
import type { PrismaScalarType } from '$lib/svelte-admin/types';
import { fieldNameToLabel, resolveFieldValue } from '$lib/svelte-admin/introspect';
let {
records = [],
listFields = [],
searchFields = [],
fieldTypes = {} as Record<string, PrismaScalarType>,
singular = 'record',
draggable = false,
copyableFields = [] as string[],
onRowClick,
onNew,
onReorder
}: {
records: Record<string, unknown>[];
listFields: string[];
searchFields?: string[];
fieldTypes?: Record<string, PrismaScalarType>;
singular?: string;
draggable?: boolean;
copyableFields?: string[];
onRowClick?: (record: Record<string, unknown>) => void;
onNew?: () => void;
onReorder?: (ids: (string | number)[]) => void;
} = $props();
let search = $state('');
let sortCol = $state<string | null>(null);
let sortAsc = $state(true);
let filtered = $derived(
search
? records.filter((r) =>
searchFields.some((f) => {
const v = resolveFieldValue(r, f);
return v != null && String(v).toLowerCase().includes(search.toLowerCase());
})
)
: records
);
let sorted = $derived(
sortCol
? [...filtered].sort((a, b) => {
const va = resolveFieldValue(a, sortCol!);
const vb = resolveFieldValue(b, sortCol!);
if (va == null) return 1;
if (vb == null) return -1;
const cmp = String(va).localeCompare(String(vb), undefined, { numeric: true });
return sortAsc ? cmp : -cmp;
})
: filtered
);
let isDragging = $state(false);
let dragOverride = $state<Record<string, unknown>[] | null>(null);
let rows = $derived(dragOverride ?? sorted);
let dragIndex = $state<number | null>(null);
let hoverIndex = $state<number | null>(null);
function toggleSort(field: string) {
if (sortCol === field) {
sortAsc = !sortAsc;
} else {
sortCol = field;
sortAsc = true;
}
}
function handleDragStart(e: DragEvent, i: number) {
dragOverride = sorted;
isDragging = true;
dragIndex = i;
hoverIndex = i;
e.dataTransfer!.effectAllowed = 'move';
e.dataTransfer!.setData('text/plain', '');
}
function handleDragOver(e: DragEvent, i: number) {
if (dragIndex === null) return;
e.preventDefault();
if (i === hoverIndex) return;
e.dataTransfer!.dropEffect = 'move';
const reordered = rows.slice();
const [moved] = reordered.splice(dragIndex, 1);
reordered.splice(i, 0, moved);
dragOverride = reordered;
dragIndex = i;
hoverIndex = i;
}
async function handleDrop() {
if (dragIndex === null) return;
const ids = rows.map((r) => r.id as string | number);
await onReorder?.(ids);
isDragging = false;
dragIndex = null;
hoverIndex = null;
}
function handleDragEnd() {
dragOverride = null;
isDragging = false;
dragIndex = null;
hoverIndex = null;
}
function copyValue(v: unknown) {
navigator.clipboard.writeText(String(v ?? ''));
}
function formatCellValue(v: unknown, field: string): string {
if (v == null) return '';
const ft = fieldTypes[field];
if (ft === 'DateTime') {
if (v instanceof Date) return v.toLocaleString();
return new Date(v as string).toLocaleString();
}
if (ft === 'Boolean') return v ? 'Active' : 'Inactive';
if (ft === 'Float') {
const n = Number(v);
return Number.isNaN(n) ? String(v) : n.toFixed(2);
}
return String(v);
}
</script>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between gap-4">
{#if searchFields.length > 0}
<input
type="text"
placeholder="Search {singular}s..."
bind:value={search}
class="max-w-xs border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
{:else}
<div></div>
{/if}
{#if onNew}
<button
type="button"
onclick={onNew}
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"
>
New {singular} +
</button>
{/if}
</div>
<div class="overflow-x-auto border border-gray-200 dark:border-gray-700">
<table class="w-full">
<thead>
<tr class="border-b border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800">
{#if draggable}
<th scope="col" class="w-10 px-2 py-2"></th>
{/if}
{#each listFields as field (field)}
<th
scope="col"
onclick={() => toggleSort(field)}
class="cursor-pointer px-4 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
{fieldNameToLabel(field)}
{#if sortCol === field}
<span class="ml-1">{sortAsc ? '\u25B2' : '\u25BC'}</span>
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each rows as record, i ((record['id'] as string) ?? (record['key'] as string) ?? i)}
<tr
{draggable}
ondragstart={(e) => handleDragStart(e, i)}
ondragover={(e) => handleDragOver(e, i)}
ondrop={handleDrop}
ondragend={handleDragEnd}
onclick={() => onRowClick?.(record)}
class="border-b border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800 {draggable &&
isDragging &&
dragIndex === i
? 'opacity-40'
: ''} {draggable && isDragging && hoverIndex === i
? 'border-t-2 border-t-blue-500'
: ''}"
>
{#if draggable}
<td
class="w-10 cursor-grab px-2 py-2 text-center"
onclick={(e) => e.stopPropagation()}
>
<span class="text-gray-400 select-none">⠿</span>
</td>
{/if}
{#each listFields as field (field)}
<td class="px-4 py-2 text-sm">
<span class="inline-flex items-center gap-1.5">
{formatCellValue(resolveFieldValue(record, field), field)}
{#if copyableFields.includes(field)}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
copyValue(resolveFieldValue(record, field));
}}
title="Copy to clipboard"
class="shrink-0 leading-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg
class="size-3.5"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3.5" y="2.5" width="11" height="11" rx="1" />
<path d="M5.5 2.5V1.5a1 1 0 011-1h3a1 1 0 011 1v1" />
</svg>
</button>
{/if}
</span>
</td>
{/each}
</tr>
{:else}
<tr>
<td
colspan={listFields.length + (draggable ? 1 : 0)}
class="px-4 py-8 text-center text-sm text-gray-500 dark:text-gray-400"
>
No {singular}s found.
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
+11 -15
View File
@@ -1,11 +1,5 @@
<script lang="ts">
let {
message = '',
type = 'success' as 'success' | 'error'
}: {
message?: string;
type?: 'success' | 'error';
} = $props();
let { message = '' }: { message?: string } = $props();
let remaining = $state(5);
let visible = $state(true);
@@ -32,15 +26,17 @@
{#if visible}
<div
role="alert"
class="fixed bottom-4 left-4 z-50 flex min-w-64 items-center gap-3 border px-4 py-3 text-sm shadow-lg {type ===
'success'
? 'border-green-600 bg-green-50 text-green-800'
: 'border-red-600 bg-red-50 text-red-800'}"
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"
>
<span class="flex-1">{message}</span>
<span class="text-xs tabular-nums opacity-70">{remaining}s</span>
<button type="button" onclick={dismiss} class="ml-1 leading-none font-bold hover:opacity-70"
>&times;</button
<span>{message}</span>
<span class="text-xs text-slate-400">{remaining}s</span>
<button
type="button"
onclick={dismiss}
aria-label="Dismiss"
class="rounded p-1 text-slate-400 transition hover:text-white"
>
&times;
</button>
</div>
{/if}
+4 -2
View File
@@ -2,6 +2,8 @@
let { children } = $props();
</script>
<div class="flex min-h-0 flex-1 items-center justify-center overflow-auto">
{@render children()}
<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">
{@render children()}
</div>
</div>
-103
View File
@@ -1,103 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
let { initialContent = '', name = 'content' }: { initialContent?: string; name?: string } =
$props();
let element: HTMLElement;
let editor: Editor;
let content = $state('');
onMount(() => {
content = initialContent;
editor = new Editor({
element,
extensions: [
StarterKit.configure({
bulletList: false,
orderedList: false
}),
TextStyle,
Color,
Highlight.configure({ multicolor: true })
],
content: initialContent,
editorProps: {
attributes: {
class:
'prose prose-sm max-w-none outline-none h-full min-h-40 p-3 dark:prose-invert dark:text-gray-100'
}
},
onUpdate: ({ editor }) => {
content = editor.getHTML();
}
});
editor.setEditable(true);
return () => editor.destroy();
});
function toggleBold() {
editor?.chain().focus().toggleBold().run();
}
function toggleItalic() {
editor?.chain().focus().toggleItalic().run();
}
function toggleHighlight() {
editor?.chain().focus().toggleHighlight().run();
}
function setColor() {
const current = editor?.getAttributes('textStyle').color ?? '#000000';
const colors = ['#000000', '#FF0000', '#0000FF', '#008000', '#FFA500', '#800080', '#808080'];
const idx = colors.indexOf(current);
const next = colors[(idx + 1) % colors.length];
editor?.chain().focus().setColor(next).run();
}
</script>
<div class="border border-gray-300 dark:border-gray-600">
<div
class="flex gap-1 border-b border-gray-300 bg-gray-100 p-2 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
>
<button
type="button"
onclick={toggleBold}
class="min-w-8 px-2 py-1 font-bold hover:bg-gray-200 dark:hover:bg-gray-700"
title="Bold">B</button
>
<button
type="button"
onclick={toggleItalic}
class="min-w-8 px-2 py-1 italic hover:bg-gray-200 dark:hover:bg-gray-700"
title="Italic">I</button
>
<button
type="button"
onclick={toggleHighlight}
class="min-w-8 px-2 py-1 hover:bg-gray-200 dark:hover:bg-gray-700"
title="Highlight"><span class="bg-yellow-200 px-1 dark:bg-yellow-600">H</span></button
>
<button
type="button"
onclick={setColor}
class="min-w-8 px-2 py-1 hover:bg-gray-200 dark:hover:bg-gray-700"
title="Cycle text color">A</button
>
</div>
<div
bind:this={element}
role="textbox"
tabindex="0"
contenteditable="true"
class="min-h-40 cursor-text bg-white dark:bg-gray-900"
></div>
</div>
<input type="hidden" {name} value={content} />
+5 -10
View File
@@ -57,6 +57,11 @@ export type Verification = Prisma.VerificationModel
*
*/
export type Proclamation = Prisma.ProclamationModel
/**
* Model ProclamationGroup
*
*/
export type ProclamationGroup = Prisma.ProclamationGroupModel
/**
* Model UserProclamations
*
@@ -72,11 +77,6 @@ export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
*
*/
export type ProclamationCategory = Prisma.ProclamationCategoryModel
/**
* Model ProclamationCategorySubscriptionLevel
*
*/
export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategorySubscriptionLevelModel
/**
* Model LicenseKey
*
@@ -87,11 +87,6 @@ export type LicenseKey = Prisma.LicenseKeyModel
*
*/
export type ApiKey = Prisma.ApiKeyModel
/**
* Model SubscriptionLevel
*
*/
export type SubscriptionLevel = Prisma.SubscriptionLevelModel
/**
* Model ColorPalette
*
+5 -10
View File
@@ -81,6 +81,11 @@ export type Verification = Prisma.VerificationModel
*
*/
export type Proclamation = Prisma.ProclamationModel
/**
* Model ProclamationGroup
*
*/
export type ProclamationGroup = Prisma.ProclamationGroupModel
/**
* Model UserProclamations
*
@@ -96,11 +101,6 @@ export type SolfeggioFrequency = Prisma.SolfeggioFrequencyModel
*
*/
export type ProclamationCategory = Prisma.ProclamationCategoryModel
/**
* Model ProclamationCategorySubscriptionLevel
*
*/
export type ProclamationCategorySubscriptionLevel = Prisma.ProclamationCategorySubscriptionLevelModel
/**
* Model LicenseKey
*
@@ -111,11 +111,6 @@ export type LicenseKey = Prisma.LicenseKeyModel
*
*/
export type ApiKey = Prisma.ApiKeyModel
/**
* Model SubscriptionLevel
*
*/
export type SubscriptionLevel = Prisma.SubscriptionLevelModel
/**
* Model ColorPalette
*
File diff suppressed because one or more lines are too long
@@ -392,13 +392,12 @@ export const ModelName = {
UserRole: 'UserRole',
Verification: 'Verification',
Proclamation: 'Proclamation',
ProclamationGroup: 'ProclamationGroup',
UserProclamations: 'UserProclamations',
SolfeggioFrequency: 'SolfeggioFrequency',
ProclamationCategory: 'ProclamationCategory',
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
SubscriptionLevel: 'SubscriptionLevel',
ColorPalette: 'ColorPalette'
} as const
@@ -415,7 +414,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "proclamationCategorySubscriptionLevel" | "licenseKey" | "apiKey" | "subscriptionLevel" | "colorPalette"
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1011,6 +1010,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
ProclamationGroup: {
payload: Prisma.$ProclamationGroupPayload<ExtArgs>
fields: Prisma.ProclamationGroupFieldRefs
operations: {
findUnique: {
args: Prisma.ProclamationGroupFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ProclamationGroupFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload>
}
findFirst: {
args: Prisma.ProclamationGroupFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload> | null
}
findFirstOrThrow: {
args: Prisma.ProclamationGroupFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload>
}
findMany: {
args: Prisma.ProclamationGroupFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload>[]
}
create: {
args: Prisma.ProclamationGroupCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload>
}
createMany: {
args: Prisma.ProclamationGroupCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ProclamationGroupCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload>[]
}
delete: {
args: Prisma.ProclamationGroupDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload>
}
update: {
args: Prisma.ProclamationGroupUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload>
}
deleteMany: {
args: Prisma.ProclamationGroupDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ProclamationGroupUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ProclamationGroupUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload>[]
}
upsert: {
args: Prisma.ProclamationGroupUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationGroupPayload>
}
aggregate: {
args: Prisma.ProclamationGroupAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateProclamationGroup>
}
groupBy: {
args: Prisma.ProclamationGroupGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ProclamationGroupGroupByOutputType>[]
}
count: {
args: Prisma.ProclamationGroupCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ProclamationGroupCountAggregateOutputType> | number
}
}
}
UserProclamations: {
payload: Prisma.$UserProclamationsPayload<ExtArgs>
fields: Prisma.UserProclamationsFieldRefs
@@ -1233,80 +1306,6 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
ProclamationCategorySubscriptionLevel: {
payload: Prisma.$ProclamationCategorySubscriptionLevelPayload<ExtArgs>
fields: Prisma.ProclamationCategorySubscriptionLevelFieldRefs
operations: {
findUnique: {
args: Prisma.ProclamationCategorySubscriptionLevelFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload> | null
}
findUniqueOrThrow: {
args: Prisma.ProclamationCategorySubscriptionLevelFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload>
}
findFirst: {
args: Prisma.ProclamationCategorySubscriptionLevelFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload> | null
}
findFirstOrThrow: {
args: Prisma.ProclamationCategorySubscriptionLevelFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload>
}
findMany: {
args: Prisma.ProclamationCategorySubscriptionLevelFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload>[]
}
create: {
args: Prisma.ProclamationCategorySubscriptionLevelCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload>
}
createMany: {
args: Prisma.ProclamationCategorySubscriptionLevelCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ProclamationCategorySubscriptionLevelCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload>[]
}
delete: {
args: Prisma.ProclamationCategorySubscriptionLevelDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload>
}
update: {
args: Prisma.ProclamationCategorySubscriptionLevelUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload>
}
deleteMany: {
args: Prisma.ProclamationCategorySubscriptionLevelDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ProclamationCategorySubscriptionLevelUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ProclamationCategorySubscriptionLevelUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload>[]
}
upsert: {
args: Prisma.ProclamationCategorySubscriptionLevelUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$ProclamationCategorySubscriptionLevelPayload>
}
aggregate: {
args: Prisma.ProclamationCategorySubscriptionLevelAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateProclamationCategorySubscriptionLevel>
}
groupBy: {
args: Prisma.ProclamationCategorySubscriptionLevelGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ProclamationCategorySubscriptionLevelGroupByOutputType>[]
}
count: {
args: Prisma.ProclamationCategorySubscriptionLevelCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ProclamationCategorySubscriptionLevelCountAggregateOutputType> | number
}
}
}
LicenseKey: {
payload: Prisma.$LicenseKeyPayload<ExtArgs>
fields: Prisma.LicenseKeyFieldRefs
@@ -1455,80 +1454,6 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
SubscriptionLevel: {
payload: Prisma.$SubscriptionLevelPayload<ExtArgs>
fields: Prisma.SubscriptionLevelFieldRefs
operations: {
findUnique: {
args: Prisma.SubscriptionLevelFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload> | null
}
findUniqueOrThrow: {
args: Prisma.SubscriptionLevelFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload>
}
findFirst: {
args: Prisma.SubscriptionLevelFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload> | null
}
findFirstOrThrow: {
args: Prisma.SubscriptionLevelFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload>
}
findMany: {
args: Prisma.SubscriptionLevelFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload>[]
}
create: {
args: Prisma.SubscriptionLevelCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload>
}
createMany: {
args: Prisma.SubscriptionLevelCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.SubscriptionLevelCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload>[]
}
delete: {
args: Prisma.SubscriptionLevelDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload>
}
update: {
args: Prisma.SubscriptionLevelUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload>
}
deleteMany: {
args: Prisma.SubscriptionLevelDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.SubscriptionLevelUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.SubscriptionLevelUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload>[]
}
upsert: {
args: Prisma.SubscriptionLevelUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SubscriptionLevelPayload>
}
aggregate: {
args: Prisma.SubscriptionLevelAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSubscriptionLevel>
}
groupBy: {
args: Prisma.SubscriptionLevelGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SubscriptionLevelGroupByOutputType>[]
}
count: {
args: Prisma.SubscriptionLevelCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.SubscriptionLevelCountAggregateOutputType> | number
}
}
}
ColorPalette: {
payload: Prisma.$ColorPalettePayload<ExtArgs>
fields: Prisma.ColorPaletteFieldRefs
@@ -1659,6 +1584,7 @@ export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof User
export const ProfileScalarFieldEnum = {
id: 'id',
userId: 'userId',
email: 'email',
firstName: 'firstName',
lastName: 'lastName',
phoneNumber: 'phoneNumber',
@@ -1744,12 +1670,21 @@ export const ProclamationScalarFieldEnum = {
content: 'content',
solfeggioFrequencyId: 'solfeggioFrequencyId',
categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId'
groupId: 'groupId'
} as const
export type ProclamationScalarFieldEnum = (typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum]
export const ProclamationGroupScalarFieldEnum = {
id: 'id',
name: 'name',
sortOrder: 'sortOrder'
} as const
export type ProclamationGroupScalarFieldEnum = (typeof ProclamationGroupScalarFieldEnum)[keyof typeof ProclamationGroupScalarFieldEnum]
export const UserProclamationsScalarFieldEnum = {
userId: 'userId',
proclamationId: 'proclamationId',
@@ -1772,27 +1707,18 @@ export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalar
export const ProclamationCategoryScalarFieldEnum = {
id: 'id',
name: 'name',
sortOrder: 'sortOrder',
parentId: 'parentId'
sortOrder: 'sortOrder'
} as const
export type ProclamationCategoryScalarFieldEnum = (typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum]
export const ProclamationCategorySubscriptionLevelScalarFieldEnum = {
categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId'
} as const
export type ProclamationCategorySubscriptionLevelScalarFieldEnum = (typeof ProclamationCategorySubscriptionLevelScalarFieldEnum)[keyof typeof ProclamationCategorySubscriptionLevelScalarFieldEnum]
export const LicenseKeyScalarFieldEnum = {
id: 'id',
key: 'key',
active: 'active',
level: 'level',
userId: 'userId'
userId: 'userId',
email: 'email'
} as const
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
@@ -1810,15 +1736,6 @@ export const ApiKeyScalarFieldEnum = {
export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
export const SubscriptionLevelScalarFieldEnum = {
id: 'id',
name: 'name',
description: 'description'
} as const
export type SubscriptionLevelScalarFieldEnum = (typeof SubscriptionLevelScalarFieldEnum)[keyof typeof SubscriptionLevelScalarFieldEnum]
export const ColorPaletteScalarFieldEnum = {
solfeggioFrequencyId: 'solfeggioFrequencyId',
colors: 'colors'
@@ -2067,13 +1984,12 @@ export type GlobalOmitConfig = {
userRole?: Prisma.UserRoleOmit
verification?: Prisma.VerificationOmit
proclamation?: Prisma.ProclamationOmit
proclamationGroup?: Prisma.ProclamationGroupOmit
userProclamations?: Prisma.UserProclamationsOmit
solfeggioFrequency?: Prisma.SolfeggioFrequencyOmit
proclamationCategory?: Prisma.ProclamationCategoryOmit
proclamationCategorySubscriptionLevel?: Prisma.ProclamationCategorySubscriptionLevelOmit
licenseKey?: Prisma.LicenseKeyOmit
apiKey?: Prisma.ApiKeyOmit
subscriptionLevel?: Prisma.SubscriptionLevelOmit
colorPalette?: Prisma.ColorPaletteOmit
}
@@ -59,13 +59,12 @@ export const ModelName = {
UserRole: 'UserRole',
Verification: 'Verification',
Proclamation: 'Proclamation',
ProclamationGroup: 'ProclamationGroup',
UserProclamations: 'UserProclamations',
SolfeggioFrequency: 'SolfeggioFrequency',
ProclamationCategory: 'ProclamationCategory',
ProclamationCategorySubscriptionLevel: 'ProclamationCategorySubscriptionLevel',
LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey',
SubscriptionLevel: 'SubscriptionLevel',
ColorPalette: 'ColorPalette'
} as const
@@ -102,6 +101,7 @@ export type UserScalarFieldEnum = (typeof UserScalarFieldEnum)[keyof typeof User
export const ProfileScalarFieldEnum = {
id: 'id',
userId: 'userId',
email: 'email',
firstName: 'firstName',
lastName: 'lastName',
phoneNumber: 'phoneNumber',
@@ -187,12 +187,21 @@ export const ProclamationScalarFieldEnum = {
content: 'content',
solfeggioFrequencyId: 'solfeggioFrequencyId',
categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId'
groupId: 'groupId'
} as const
export type ProclamationScalarFieldEnum = (typeof ProclamationScalarFieldEnum)[keyof typeof ProclamationScalarFieldEnum]
export const ProclamationGroupScalarFieldEnum = {
id: 'id',
name: 'name',
sortOrder: 'sortOrder'
} as const
export type ProclamationGroupScalarFieldEnum = (typeof ProclamationGroupScalarFieldEnum)[keyof typeof ProclamationGroupScalarFieldEnum]
export const UserProclamationsScalarFieldEnum = {
userId: 'userId',
proclamationId: 'proclamationId',
@@ -215,27 +224,18 @@ export type SolfeggioFrequencyScalarFieldEnum = (typeof SolfeggioFrequencyScalar
export const ProclamationCategoryScalarFieldEnum = {
id: 'id',
name: 'name',
sortOrder: 'sortOrder',
parentId: 'parentId'
sortOrder: 'sortOrder'
} as const
export type ProclamationCategoryScalarFieldEnum = (typeof ProclamationCategoryScalarFieldEnum)[keyof typeof ProclamationCategoryScalarFieldEnum]
export const ProclamationCategorySubscriptionLevelScalarFieldEnum = {
categoryId: 'categoryId',
subscriptionLevelId: 'subscriptionLevelId'
} as const
export type ProclamationCategorySubscriptionLevelScalarFieldEnum = (typeof ProclamationCategorySubscriptionLevelScalarFieldEnum)[keyof typeof ProclamationCategorySubscriptionLevelScalarFieldEnum]
export const LicenseKeyScalarFieldEnum = {
id: 'id',
key: 'key',
active: 'active',
level: 'level',
userId: 'userId'
userId: 'userId',
email: 'email'
} as const
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
@@ -253,15 +253,6 @@ export const ApiKeyScalarFieldEnum = {
export type ApiKeyScalarFieldEnum = (typeof ApiKeyScalarFieldEnum)[keyof typeof ApiKeyScalarFieldEnum]
export const SubscriptionLevelScalarFieldEnum = {
id: 'id',
name: 'name',
description: 'description'
} as const
export type SubscriptionLevelScalarFieldEnum = (typeof SubscriptionLevelScalarFieldEnum)[keyof typeof SubscriptionLevelScalarFieldEnum]
export const ColorPaletteScalarFieldEnum = {
solfeggioFrequencyId: 'solfeggioFrequencyId',
colors: 'colors'
+1 -2
View File
@@ -16,12 +16,11 @@ export type * from './models/Account.ts'
export type * from './models/UserRole.ts'
export type * from './models/Verification.ts'
export type * from './models/Proclamation.ts'
export type * from './models/ProclamationGroup.ts'
export type * from './models/UserProclamations.ts'
export type * from './models/SolfeggioFrequency.ts'
export type * from './models/ProclamationCategory.ts'
export type * from './models/ProclamationCategorySubscriptionLevel.ts'
export type * from './models/LicenseKey.ts'
export type * from './models/ApiKey.ts'
export type * from './models/SubscriptionLevel.ts'
export type * from './models/ColorPalette.ts'
export type * from './commonInputTypes.ts'
+79 -60
View File
@@ -28,24 +28,24 @@ export type LicenseKeyMinAggregateOutputType = {
id: string | null
key: string | null
active: boolean | null
level: string | null
userId: string | null
email: string | null
}
export type LicenseKeyMaxAggregateOutputType = {
id: string | null
key: string | null
active: boolean | null
level: string | null
userId: string | null
email: string | null
}
export type LicenseKeyCountAggregateOutputType = {
id: number
key: number
active: number
level: number
userId: number
email: number
_all: number
}
@@ -54,24 +54,24 @@ export type LicenseKeyMinAggregateInputType = {
id?: true
key?: true
active?: true
level?: true
userId?: true
email?: true
}
export type LicenseKeyMaxAggregateInputType = {
id?: true
key?: true
active?: true
level?: true
userId?: true
email?: true
}
export type LicenseKeyCountAggregateInputType = {
id?: true
key?: true
active?: true
level?: true
userId?: true
email?: true
_all?: true
}
@@ -151,8 +151,8 @@ export type LicenseKeyGroupByOutputType = {
id: string
key: string
active: boolean
level: string
userId: string
userId: string | null
email: string | null
_count: LicenseKeyCountAggregateOutputType | null
_min: LicenseKeyMinAggregateOutputType | null
_max: LicenseKeyMaxAggregateOutputType | null
@@ -180,17 +180,17 @@ export type LicenseKeyWhereInput = {
id?: Prisma.StringFilter<"LicenseKey"> | string
key?: Prisma.StringFilter<"LicenseKey"> | string
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
level?: Prisma.StringFilter<"LicenseKey"> | string
userId?: Prisma.StringFilter<"LicenseKey"> | string
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
}
export type LicenseKeyOrderByWithRelationInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
active?: Prisma.SortOrder
level?: Prisma.SortOrder
userId?: Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder
email?: Prisma.SortOrderInput | Prisma.SortOrder
user?: Prisma.UserOrderByWithRelationInput
}
@@ -201,17 +201,17 @@ export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{
OR?: Prisma.LicenseKeyWhereInput[]
NOT?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
level?: Prisma.StringFilter<"LicenseKey"> | string
userId?: Prisma.StringFilter<"LicenseKey"> | string
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
}, "id" | "key">
export type LicenseKeyOrderByWithAggregationInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
active?: Prisma.SortOrder
level?: Prisma.SortOrder
userId?: Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder
email?: Prisma.SortOrderInput | Prisma.SortOrder
_count?: Prisma.LicenseKeyCountOrderByAggregateInput
_max?: Prisma.LicenseKeyMaxOrderByAggregateInput
_min?: Prisma.LicenseKeyMinOrderByAggregateInput
@@ -224,63 +224,63 @@ export type LicenseKeyScalarWhereWithAggregatesInput = {
id?: Prisma.StringWithAggregatesFilter<"LicenseKey"> | string
key?: Prisma.StringWithAggregatesFilter<"LicenseKey"> | string
active?: Prisma.BoolWithAggregatesFilter<"LicenseKey"> | boolean
level?: Prisma.StringWithAggregatesFilter<"LicenseKey"> | string
userId?: Prisma.StringWithAggregatesFilter<"LicenseKey"> | string
userId?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
}
export type LicenseKeyCreateInput = {
id?: string
key: string
active: boolean
level: string
user: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
email?: string | null
user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
}
export type LicenseKeyUncheckedCreateInput = {
id?: string
key: string
active: boolean
level: string
userId: string
userId?: string | null
email?: string | null
}
export type LicenseKeyUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
level?: Prisma.StringFieldUpdateOperationsInput | string
user?: Prisma.UserUpdateOneRequiredWithoutLicenseKeysNestedInput
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
user?: Prisma.UserUpdateOneWithoutLicenseKeysNestedInput
}
export type LicenseKeyUncheckedUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
level?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type LicenseKeyCreateManyInput = {
id?: string
key: string
active: boolean
level: string
userId: string
userId?: string | null
email?: string | null
}
export type LicenseKeyUpdateManyMutationInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
level?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type LicenseKeyUncheckedUpdateManyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
level?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type LicenseKeyListRelationFilter = {
@@ -297,24 +297,24 @@ export type LicenseKeyCountOrderByAggregateInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
active?: Prisma.SortOrder
level?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
}
export type LicenseKeyMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
active?: Prisma.SortOrder
level?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
}
export type LicenseKeyMinOrderByAggregateInput = {
id?: Prisma.SortOrder
key?: Prisma.SortOrder
active?: Prisma.SortOrder
level?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
}
export type LicenseKeyCreateNestedManyWithoutUserInput = {
@@ -363,14 +363,14 @@ export type LicenseKeyCreateWithoutUserInput = {
id?: string
key: string
active: boolean
level: string
email?: string | null
}
export type LicenseKeyUncheckedCreateWithoutUserInput = {
id?: string
key: string
active: boolean
level: string
email?: string | null
}
export type LicenseKeyCreateOrConnectWithoutUserInput = {
@@ -406,36 +406,36 @@ export type LicenseKeyScalarWhereInput = {
id?: Prisma.StringFilter<"LicenseKey"> | string
key?: Prisma.StringFilter<"LicenseKey"> | string
active?: Prisma.BoolFilter<"LicenseKey"> | boolean
level?: Prisma.StringFilter<"LicenseKey"> | string
userId?: Prisma.StringFilter<"LicenseKey"> | string
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
}
export type LicenseKeyCreateManyUserInput = {
id?: string
key: string
active: boolean
level: string
email?: string | null
}
export type LicenseKeyUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
level?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type LicenseKeyUncheckedUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
level?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type LicenseKeyUncheckedUpdateManyWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
key?: Prisma.StringFieldUpdateOperationsInput | string
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
level?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
@@ -444,59 +444,59 @@ export type LicenseKeySelect<ExtArgs extends runtime.Types.Extensions.InternalAr
id?: boolean
key?: boolean
active?: boolean
level?: boolean
userId?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
email?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}, ExtArgs["result"]["licenseKey"]>
export type LicenseKeySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
key?: boolean
active?: boolean
level?: boolean
userId?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
email?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}, ExtArgs["result"]["licenseKey"]>
export type LicenseKeySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
key?: boolean
active?: boolean
level?: boolean
userId?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
email?: boolean
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}, ExtArgs["result"]["licenseKey"]>
export type LicenseKeySelectScalar = {
id?: boolean
key?: boolean
active?: boolean
level?: boolean
userId?: boolean
email?: boolean
}
export type LicenseKeyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "key" | "active" | "level" | "userId", ExtArgs["result"]["licenseKey"]>
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 LicenseKeyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}
export type LicenseKeyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}
export type LicenseKeyIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
}
export type $LicenseKeyPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "LicenseKey"
objects: {
user: Prisma.$UserPayload<ExtArgs>
user: Prisma.$UserPayload<ExtArgs> | null
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
key: string
active: boolean
level: string
userId: string
userId: string | null
email: string | null
}, ExtArgs["result"]["licenseKey"]>
composites: {}
}
@@ -891,7 +891,7 @@ readonly fields: LicenseKeyFieldRefs;
*/
export interface Prisma__LicenseKeyClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
user<T extends Prisma.UserDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.UserDefaultArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
user<T extends Prisma.LicenseKey$userArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.LicenseKey$userArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -924,8 +924,8 @@ export interface LicenseKeyFieldRefs {
readonly id: Prisma.FieldRef<"LicenseKey", 'String'>
readonly key: Prisma.FieldRef<"LicenseKey", 'String'>
readonly active: Prisma.FieldRef<"LicenseKey", 'Boolean'>
readonly level: Prisma.FieldRef<"LicenseKey", 'String'>
readonly userId: Prisma.FieldRef<"LicenseKey", 'String'>
readonly email: Prisma.FieldRef<"LicenseKey", 'String'>
}
@@ -1326,6 +1326,25 @@ export type LicenseKeyDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.In
limit?: number
}
/**
* LicenseKey.user
*/
export type LicenseKey$userArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the User
*/
select?: Prisma.UserSelect<ExtArgs> | null
/**
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
where?: Prisma.UserWhereInput
}
/**
* LicenseKey without action
*/
+177 -177
View File
@@ -29,13 +29,13 @@ export type AggregateProclamation = {
export type ProclamationAvgAggregateOutputType = {
solfeggioFrequencyId: number | null
categoryId: number | null
subscriptionLevelId: number | null
groupId: number | null
}
export type ProclamationSumAggregateOutputType = {
solfeggioFrequencyId: number | null
categoryId: number | null
subscriptionLevelId: number | null
groupId: number | null
}
export type ProclamationMinAggregateOutputType = {
@@ -44,7 +44,7 @@ export type ProclamationMinAggregateOutputType = {
content: string | null
solfeggioFrequencyId: number | null
categoryId: number | null
subscriptionLevelId: number | null
groupId: number | null
}
export type ProclamationMaxAggregateOutputType = {
@@ -53,7 +53,7 @@ export type ProclamationMaxAggregateOutputType = {
content: string | null
solfeggioFrequencyId: number | null
categoryId: number | null
subscriptionLevelId: number | null
groupId: number | null
}
export type ProclamationCountAggregateOutputType = {
@@ -62,7 +62,7 @@ export type ProclamationCountAggregateOutputType = {
content: number
solfeggioFrequencyId: number
categoryId: number
subscriptionLevelId: number
groupId: number
_all: number
}
@@ -70,13 +70,13 @@ export type ProclamationCountAggregateOutputType = {
export type ProclamationAvgAggregateInputType = {
solfeggioFrequencyId?: true
categoryId?: true
subscriptionLevelId?: true
groupId?: true
}
export type ProclamationSumAggregateInputType = {
solfeggioFrequencyId?: true
categoryId?: true
subscriptionLevelId?: true
groupId?: true
}
export type ProclamationMinAggregateInputType = {
@@ -85,7 +85,7 @@ export type ProclamationMinAggregateInputType = {
content?: true
solfeggioFrequencyId?: true
categoryId?: true
subscriptionLevelId?: true
groupId?: true
}
export type ProclamationMaxAggregateInputType = {
@@ -94,7 +94,7 @@ export type ProclamationMaxAggregateInputType = {
content?: true
solfeggioFrequencyId?: true
categoryId?: true
subscriptionLevelId?: true
groupId?: true
}
export type ProclamationCountAggregateInputType = {
@@ -103,7 +103,7 @@ export type ProclamationCountAggregateInputType = {
content?: true
solfeggioFrequencyId?: true
categoryId?: true
subscriptionLevelId?: true
groupId?: true
_all?: true
}
@@ -199,7 +199,7 @@ export type ProclamationGroupByOutputType = {
content: string
solfeggioFrequencyId: number
categoryId: number
subscriptionLevelId: number
groupId: number
_count: ProclamationCountAggregateOutputType | null
_avg: ProclamationAvgAggregateOutputType | null
_sum: ProclamationSumAggregateOutputType | null
@@ -231,11 +231,11 @@ export type ProclamationWhereInput = {
content?: Prisma.StringFilter<"Proclamation"> | string
solfeggioFrequencyId?: Prisma.IntFilter<"Proclamation"> | number
categoryId?: Prisma.IntFilter<"Proclamation"> | number
subscriptionLevelId?: Prisma.IntFilter<"Proclamation"> | number
groupId?: Prisma.IntFilter<"Proclamation"> | number
solfeggioFrequency?: Prisma.XOR<Prisma.SolfeggioFrequencyScalarRelationFilter, Prisma.SolfeggioFrequencyWhereInput>
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
userProclamations?: Prisma.UserProclamationsListRelationFilter
subscriptionLevel?: Prisma.XOR<Prisma.SubscriptionLevelScalarRelationFilter, Prisma.SubscriptionLevelWhereInput>
}
export type ProclamationOrderByWithRelationInput = {
@@ -244,11 +244,11 @@ export type ProclamationOrderByWithRelationInput = {
content?: Prisma.SortOrder
solfeggioFrequencyId?: Prisma.SortOrder
categoryId?: Prisma.SortOrder
subscriptionLevelId?: Prisma.SortOrder
groupId?: Prisma.SortOrder
solfeggioFrequency?: Prisma.SolfeggioFrequencyOrderByWithRelationInput
category?: Prisma.ProclamationCategoryOrderByWithRelationInput
group?: Prisma.ProclamationGroupOrderByWithRelationInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
subscriptionLevel?: Prisma.SubscriptionLevelOrderByWithRelationInput
}
export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
@@ -260,11 +260,11 @@ export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
content?: Prisma.StringFilter<"Proclamation"> | string
solfeggioFrequencyId?: Prisma.IntFilter<"Proclamation"> | number
categoryId?: Prisma.IntFilter<"Proclamation"> | number
subscriptionLevelId?: Prisma.IntFilter<"Proclamation"> | number
groupId?: Prisma.IntFilter<"Proclamation"> | number
solfeggioFrequency?: Prisma.XOR<Prisma.SolfeggioFrequencyScalarRelationFilter, Prisma.SolfeggioFrequencyWhereInput>
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
userProclamations?: Prisma.UserProclamationsListRelationFilter
subscriptionLevel?: Prisma.XOR<Prisma.SubscriptionLevelScalarRelationFilter, Prisma.SubscriptionLevelWhereInput>
}, "id" | "name">
export type ProclamationOrderByWithAggregationInput = {
@@ -273,7 +273,7 @@ export type ProclamationOrderByWithAggregationInput = {
content?: Prisma.SortOrder
solfeggioFrequencyId?: Prisma.SortOrder
categoryId?: Prisma.SortOrder
subscriptionLevelId?: Prisma.SortOrder
groupId?: Prisma.SortOrder
_count?: Prisma.ProclamationCountOrderByAggregateInput
_avg?: Prisma.ProclamationAvgOrderByAggregateInput
_max?: Prisma.ProclamationMaxOrderByAggregateInput
@@ -290,7 +290,7 @@ export type ProclamationScalarWhereWithAggregatesInput = {
content?: Prisma.StringWithAggregatesFilter<"Proclamation"> | string
solfeggioFrequencyId?: Prisma.IntWithAggregatesFilter<"Proclamation"> | number
categoryId?: Prisma.IntWithAggregatesFilter<"Proclamation"> | number
subscriptionLevelId?: Prisma.IntWithAggregatesFilter<"Proclamation"> | number
groupId?: Prisma.IntWithAggregatesFilter<"Proclamation"> | number
}
export type ProclamationCreateInput = {
@@ -299,8 +299,8 @@ export type ProclamationCreateInput = {
content: string
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
subscriptionLevel: Prisma.SubscriptionLevelCreateNestedOneWithoutProclamationsInput
}
export type ProclamationUncheckedCreateInput = {
@@ -309,7 +309,7 @@ export type ProclamationUncheckedCreateInput = {
content: string
solfeggioFrequencyId: number
categoryId: number
subscriptionLevelId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
@@ -319,8 +319,8 @@ export type ProclamationUpdateInput = {
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
subscriptionLevel?: Prisma.SubscriptionLevelUpdateOneRequiredWithoutProclamationsNestedInput
}
export type ProclamationUncheckedUpdateInput = {
@@ -329,7 +329,7 @@ export type ProclamationUncheckedUpdateInput = {
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
subscriptionLevelId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
@@ -339,7 +339,7 @@ export type ProclamationCreateManyInput = {
content: string
solfeggioFrequencyId: number
categoryId: number
subscriptionLevelId: number
groupId: number
}
export type ProclamationUpdateManyMutationInput = {
@@ -354,7 +354,7 @@ export type ProclamationUncheckedUpdateManyInput = {
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
subscriptionLevelId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
}
export type ProclamationCountOrderByAggregateInput = {
@@ -363,13 +363,13 @@ export type ProclamationCountOrderByAggregateInput = {
content?: Prisma.SortOrder
solfeggioFrequencyId?: Prisma.SortOrder
categoryId?: Prisma.SortOrder
subscriptionLevelId?: Prisma.SortOrder
groupId?: Prisma.SortOrder
}
export type ProclamationAvgOrderByAggregateInput = {
solfeggioFrequencyId?: Prisma.SortOrder
categoryId?: Prisma.SortOrder
subscriptionLevelId?: Prisma.SortOrder
groupId?: Prisma.SortOrder
}
export type ProclamationMaxOrderByAggregateInput = {
@@ -378,7 +378,7 @@ export type ProclamationMaxOrderByAggregateInput = {
content?: Prisma.SortOrder
solfeggioFrequencyId?: Prisma.SortOrder
categoryId?: Prisma.SortOrder
subscriptionLevelId?: Prisma.SortOrder
groupId?: Prisma.SortOrder
}
export type ProclamationMinOrderByAggregateInput = {
@@ -387,18 +387,13 @@ export type ProclamationMinOrderByAggregateInput = {
content?: Prisma.SortOrder
solfeggioFrequencyId?: Prisma.SortOrder
categoryId?: Prisma.SortOrder
subscriptionLevelId?: Prisma.SortOrder
groupId?: Prisma.SortOrder
}
export type ProclamationSumOrderByAggregateInput = {
solfeggioFrequencyId?: Prisma.SortOrder
categoryId?: Prisma.SortOrder
subscriptionLevelId?: Prisma.SortOrder
}
export type ProclamationScalarRelationFilter = {
is?: Prisma.ProclamationWhereInput
isNot?: Prisma.ProclamationWhereInput
groupId?: Prisma.SortOrder
}
export type ProclamationListRelationFilter = {
@@ -411,6 +406,53 @@ export type ProclamationOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type ProclamationScalarRelationFilter = {
is?: Prisma.ProclamationWhereInput
isNot?: Prisma.ProclamationWhereInput
}
export type ProclamationCreateNestedManyWithoutGroupInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutGroupInput, Prisma.ProclamationUncheckedCreateWithoutGroupInput> | Prisma.ProclamationCreateWithoutGroupInput[] | Prisma.ProclamationUncheckedCreateWithoutGroupInput[]
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutGroupInput | Prisma.ProclamationCreateOrConnectWithoutGroupInput[]
createMany?: Prisma.ProclamationCreateManyGroupInputEnvelope
connect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
}
export type ProclamationUncheckedCreateNestedManyWithoutGroupInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutGroupInput, Prisma.ProclamationUncheckedCreateWithoutGroupInput> | Prisma.ProclamationCreateWithoutGroupInput[] | Prisma.ProclamationUncheckedCreateWithoutGroupInput[]
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutGroupInput | Prisma.ProclamationCreateOrConnectWithoutGroupInput[]
createMany?: Prisma.ProclamationCreateManyGroupInputEnvelope
connect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
}
export type ProclamationUpdateManyWithoutGroupNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutGroupInput, Prisma.ProclamationUncheckedCreateWithoutGroupInput> | Prisma.ProclamationCreateWithoutGroupInput[] | Prisma.ProclamationUncheckedCreateWithoutGroupInput[]
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutGroupInput | Prisma.ProclamationCreateOrConnectWithoutGroupInput[]
upsert?: Prisma.ProclamationUpsertWithWhereUniqueWithoutGroupInput | Prisma.ProclamationUpsertWithWhereUniqueWithoutGroupInput[]
createMany?: Prisma.ProclamationCreateManyGroupInputEnvelope
set?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
disconnect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
delete?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
connect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
update?: Prisma.ProclamationUpdateWithWhereUniqueWithoutGroupInput | Prisma.ProclamationUpdateWithWhereUniqueWithoutGroupInput[]
updateMany?: Prisma.ProclamationUpdateManyWithWhereWithoutGroupInput | Prisma.ProclamationUpdateManyWithWhereWithoutGroupInput[]
deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
}
export type ProclamationUncheckedUpdateManyWithoutGroupNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutGroupInput, Prisma.ProclamationUncheckedCreateWithoutGroupInput> | Prisma.ProclamationCreateWithoutGroupInput[] | Prisma.ProclamationUncheckedCreateWithoutGroupInput[]
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutGroupInput | Prisma.ProclamationCreateOrConnectWithoutGroupInput[]
upsert?: Prisma.ProclamationUpsertWithWhereUniqueWithoutGroupInput | Prisma.ProclamationUpsertWithWhereUniqueWithoutGroupInput[]
createMany?: Prisma.ProclamationCreateManyGroupInputEnvelope
set?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
disconnect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
delete?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
connect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
update?: Prisma.ProclamationUpdateWithWhereUniqueWithoutGroupInput | Prisma.ProclamationUpdateWithWhereUniqueWithoutGroupInput[]
updateMany?: Prisma.ProclamationUpdateManyWithWhereWithoutGroupInput | Prisma.ProclamationUpdateManyWithWhereWithoutGroupInput[]
deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
}
export type ProclamationCreateNestedOneWithoutUserProclamationsInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutUserProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutUserProclamationsInput>
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutUserProclamationsInput
@@ -509,46 +551,60 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryNestedInput = {
deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
}
export type ProclamationCreateNestedManyWithoutSubscriptionLevelInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutSubscriptionLevelInput, Prisma.ProclamationUncheckedCreateWithoutSubscriptionLevelInput> | Prisma.ProclamationCreateWithoutSubscriptionLevelInput[] | Prisma.ProclamationUncheckedCreateWithoutSubscriptionLevelInput[]
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutSubscriptionLevelInput | Prisma.ProclamationCreateOrConnectWithoutSubscriptionLevelInput[]
createMany?: Prisma.ProclamationCreateManySubscriptionLevelInputEnvelope
connect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
export type ProclamationCreateWithoutGroupInput = {
id?: string
name: string
content: string
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateNestedManyWithoutSubscriptionLevelInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutSubscriptionLevelInput, Prisma.ProclamationUncheckedCreateWithoutSubscriptionLevelInput> | Prisma.ProclamationCreateWithoutSubscriptionLevelInput[] | Prisma.ProclamationUncheckedCreateWithoutSubscriptionLevelInput[]
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutSubscriptionLevelInput | Prisma.ProclamationCreateOrConnectWithoutSubscriptionLevelInput[]
createMany?: Prisma.ProclamationCreateManySubscriptionLevelInputEnvelope
connect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
export type ProclamationUncheckedCreateWithoutGroupInput = {
id?: string
name: string
content: string
solfeggioFrequencyId: number
categoryId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationUpdateManyWithoutSubscriptionLevelNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutSubscriptionLevelInput, Prisma.ProclamationUncheckedCreateWithoutSubscriptionLevelInput> | Prisma.ProclamationCreateWithoutSubscriptionLevelInput[] | Prisma.ProclamationUncheckedCreateWithoutSubscriptionLevelInput[]
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutSubscriptionLevelInput | Prisma.ProclamationCreateOrConnectWithoutSubscriptionLevelInput[]
upsert?: Prisma.ProclamationUpsertWithWhereUniqueWithoutSubscriptionLevelInput | Prisma.ProclamationUpsertWithWhereUniqueWithoutSubscriptionLevelInput[]
createMany?: Prisma.ProclamationCreateManySubscriptionLevelInputEnvelope
set?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
disconnect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
delete?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
connect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
update?: Prisma.ProclamationUpdateWithWhereUniqueWithoutSubscriptionLevelInput | Prisma.ProclamationUpdateWithWhereUniqueWithoutSubscriptionLevelInput[]
updateMany?: Prisma.ProclamationUpdateManyWithWhereWithoutSubscriptionLevelInput | Prisma.ProclamationUpdateManyWithWhereWithoutSubscriptionLevelInput[]
deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
export type ProclamationCreateOrConnectWithoutGroupInput = {
where: Prisma.ProclamationWhereUniqueInput
create: Prisma.XOR<Prisma.ProclamationCreateWithoutGroupInput, Prisma.ProclamationUncheckedCreateWithoutGroupInput>
}
export type ProclamationUncheckedUpdateManyWithoutSubscriptionLevelNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutSubscriptionLevelInput, Prisma.ProclamationUncheckedCreateWithoutSubscriptionLevelInput> | Prisma.ProclamationCreateWithoutSubscriptionLevelInput[] | Prisma.ProclamationUncheckedCreateWithoutSubscriptionLevelInput[]
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutSubscriptionLevelInput | Prisma.ProclamationCreateOrConnectWithoutSubscriptionLevelInput[]
upsert?: Prisma.ProclamationUpsertWithWhereUniqueWithoutSubscriptionLevelInput | Prisma.ProclamationUpsertWithWhereUniqueWithoutSubscriptionLevelInput[]
createMany?: Prisma.ProclamationCreateManySubscriptionLevelInputEnvelope
set?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
disconnect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
delete?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
connect?: Prisma.ProclamationWhereUniqueInput | Prisma.ProclamationWhereUniqueInput[]
update?: Prisma.ProclamationUpdateWithWhereUniqueWithoutSubscriptionLevelInput | Prisma.ProclamationUpdateWithWhereUniqueWithoutSubscriptionLevelInput[]
updateMany?: Prisma.ProclamationUpdateManyWithWhereWithoutSubscriptionLevelInput | Prisma.ProclamationUpdateManyWithWhereWithoutSubscriptionLevelInput[]
deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
export type ProclamationCreateManyGroupInputEnvelope = {
data: Prisma.ProclamationCreateManyGroupInput | Prisma.ProclamationCreateManyGroupInput[]
skipDuplicates?: boolean
}
export type ProclamationUpsertWithWhereUniqueWithoutGroupInput = {
where: Prisma.ProclamationWhereUniqueInput
update: Prisma.XOR<Prisma.ProclamationUpdateWithoutGroupInput, Prisma.ProclamationUncheckedUpdateWithoutGroupInput>
create: Prisma.XOR<Prisma.ProclamationCreateWithoutGroupInput, Prisma.ProclamationUncheckedCreateWithoutGroupInput>
}
export type ProclamationUpdateWithWhereUniqueWithoutGroupInput = {
where: Prisma.ProclamationWhereUniqueInput
data: Prisma.XOR<Prisma.ProclamationUpdateWithoutGroupInput, Prisma.ProclamationUncheckedUpdateWithoutGroupInput>
}
export type ProclamationUpdateManyWithWhereWithoutGroupInput = {
where: Prisma.ProclamationScalarWhereInput
data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutGroupInput>
}
export type ProclamationScalarWhereInput = {
AND?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
OR?: Prisma.ProclamationScalarWhereInput[]
NOT?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
id?: Prisma.StringFilter<"Proclamation"> | string
name?: Prisma.StringFilter<"Proclamation"> | string
content?: Prisma.StringFilter<"Proclamation"> | string
solfeggioFrequencyId?: Prisma.IntFilter<"Proclamation"> | number
categoryId?: Prisma.IntFilter<"Proclamation"> | number
groupId?: Prisma.IntFilter<"Proclamation"> | number
}
export type ProclamationCreateWithoutUserProclamationsInput = {
@@ -557,7 +613,7 @@ export type ProclamationCreateWithoutUserProclamationsInput = {
content: string
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
subscriptionLevel: Prisma.SubscriptionLevelCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
}
export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
@@ -566,7 +622,7 @@ export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
content: string
solfeggioFrequencyId: number
categoryId: number
subscriptionLevelId: number
groupId: number
}
export type ProclamationCreateOrConnectWithoutUserProclamationsInput = {
@@ -591,7 +647,7 @@ export type ProclamationUpdateWithoutUserProclamationsInput = {
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
subscriptionLevel?: Prisma.SubscriptionLevelUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
}
export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
@@ -600,7 +656,7 @@ export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
subscriptionLevelId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
}
export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
@@ -608,8 +664,8 @@ export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
name: string
content: string
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
subscriptionLevel: Prisma.SubscriptionLevelCreateNestedOneWithoutProclamationsInput
}
export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
@@ -617,7 +673,7 @@ export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
name: string
content: string
categoryId: number
subscriptionLevelId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
@@ -647,25 +703,13 @@ export type ProclamationUpdateManyWithWhereWithoutSolfeggioFrequencyInput = {
data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyInput>
}
export type ProclamationScalarWhereInput = {
AND?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
OR?: Prisma.ProclamationScalarWhereInput[]
NOT?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
id?: Prisma.StringFilter<"Proclamation"> | string
name?: Prisma.StringFilter<"Proclamation"> | string
content?: Prisma.StringFilter<"Proclamation"> | string
solfeggioFrequencyId?: Prisma.IntFilter<"Proclamation"> | number
categoryId?: Prisma.IntFilter<"Proclamation"> | number
subscriptionLevelId?: Prisma.IntFilter<"Proclamation"> | number
}
export type ProclamationCreateWithoutCategoryInput = {
id?: string
name: string
content: string
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
subscriptionLevel: Prisma.SubscriptionLevelCreateNestedOneWithoutProclamationsInput
}
export type ProclamationUncheckedCreateWithoutCategoryInput = {
@@ -673,7 +717,7 @@ export type ProclamationUncheckedCreateWithoutCategoryInput = {
name: string
content: string
solfeggioFrequencyId: number
subscriptionLevelId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
@@ -703,48 +747,38 @@ export type ProclamationUpdateManyWithWhereWithoutCategoryInput = {
data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutCategoryInput>
}
export type ProclamationCreateWithoutSubscriptionLevelInput = {
id?: string
name: string
content: string
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutSubscriptionLevelInput = {
export type ProclamationCreateManyGroupInput = {
id?: string
name: string
content: string
solfeggioFrequencyId: number
categoryId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutSubscriptionLevelInput = {
where: Prisma.ProclamationWhereUniqueInput
create: Prisma.XOR<Prisma.ProclamationCreateWithoutSubscriptionLevelInput, Prisma.ProclamationUncheckedCreateWithoutSubscriptionLevelInput>
export type ProclamationUpdateWithoutGroupInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationCreateManySubscriptionLevelInputEnvelope = {
data: Prisma.ProclamationCreateManySubscriptionLevelInput | Prisma.ProclamationCreateManySubscriptionLevelInput[]
skipDuplicates?: boolean
export type ProclamationUncheckedUpdateWithoutGroupInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUpsertWithWhereUniqueWithoutSubscriptionLevelInput = {
where: Prisma.ProclamationWhereUniqueInput
update: Prisma.XOR<Prisma.ProclamationUpdateWithoutSubscriptionLevelInput, Prisma.ProclamationUncheckedUpdateWithoutSubscriptionLevelInput>
create: Prisma.XOR<Prisma.ProclamationCreateWithoutSubscriptionLevelInput, Prisma.ProclamationUncheckedCreateWithoutSubscriptionLevelInput>
}
export type ProclamationUpdateWithWhereUniqueWithoutSubscriptionLevelInput = {
where: Prisma.ProclamationWhereUniqueInput
data: Prisma.XOR<Prisma.ProclamationUpdateWithoutSubscriptionLevelInput, Prisma.ProclamationUncheckedUpdateWithoutSubscriptionLevelInput>
}
export type ProclamationUpdateManyWithWhereWithoutSubscriptionLevelInput = {
where: Prisma.ProclamationScalarWhereInput
data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutSubscriptionLevelInput>
export type ProclamationUncheckedUpdateManyWithoutGroupInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
}
export type ProclamationCreateManySolfeggioFrequencyInput = {
@@ -752,7 +786,7 @@ export type ProclamationCreateManySolfeggioFrequencyInput = {
name: string
content: string
categoryId: number
subscriptionLevelId: number
groupId: number
}
export type ProclamationUpdateWithoutSolfeggioFrequencyInput = {
@@ -760,8 +794,8 @@ export type ProclamationUpdateWithoutSolfeggioFrequencyInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
subscriptionLevel?: Prisma.SubscriptionLevelUpdateOneRequiredWithoutProclamationsNestedInput
}
export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
@@ -769,7 +803,7 @@ export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
subscriptionLevelId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
@@ -778,7 +812,7 @@ export type ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
subscriptionLevelId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
}
export type ProclamationCreateManyCategoryInput = {
@@ -786,7 +820,7 @@ export type ProclamationCreateManyCategoryInput = {
name: string
content: string
solfeggioFrequencyId: number
subscriptionLevelId: number
groupId: number
}
export type ProclamationUpdateWithoutCategoryInput = {
@@ -794,8 +828,8 @@ export type ProclamationUpdateWithoutCategoryInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
subscriptionLevel?: Prisma.SubscriptionLevelUpdateOneRequiredWithoutProclamationsNestedInput
}
export type ProclamationUncheckedUpdateWithoutCategoryInput = {
@@ -803,7 +837,7 @@ export type ProclamationUncheckedUpdateWithoutCategoryInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
subscriptionLevelId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
@@ -812,41 +846,7 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
subscriptionLevelId?: Prisma.IntFieldUpdateOperationsInput | number
}
export type ProclamationCreateManySubscriptionLevelInput = {
id?: string
name: string
content: string
solfeggioFrequencyId: number
categoryId: number
}
export type ProclamationUpdateWithoutSubscriptionLevelInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutSubscriptionLevelInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateManyWithoutSubscriptionLevelInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
}
@@ -886,11 +886,11 @@ export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.Internal
content?: boolean
solfeggioFrequencyId?: boolean
categoryId?: boolean
subscriptionLevelId?: boolean
groupId?: boolean
solfeggioFrequency?: boolean | Prisma.SolfeggioFrequencyDefaultArgs<ExtArgs>
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
subscriptionLevel?: boolean | Prisma.SubscriptionLevelDefaultArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["proclamation"]>
@@ -900,10 +900,10 @@ export type ProclamationSelectCreateManyAndReturn<ExtArgs extends runtime.Types.
content?: boolean
solfeggioFrequencyId?: boolean
categoryId?: boolean
subscriptionLevelId?: boolean
groupId?: boolean
solfeggioFrequency?: boolean | Prisma.SolfeggioFrequencyDefaultArgs<ExtArgs>
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
subscriptionLevel?: boolean | Prisma.SubscriptionLevelDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
}, ExtArgs["result"]["proclamation"]>
export type ProclamationSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
@@ -912,10 +912,10 @@ export type ProclamationSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.
content?: boolean
solfeggioFrequencyId?: boolean
categoryId?: boolean
subscriptionLevelId?: boolean
groupId?: boolean
solfeggioFrequency?: boolean | Prisma.SolfeggioFrequencyDefaultArgs<ExtArgs>
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
subscriptionLevel?: boolean | Prisma.SubscriptionLevelDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
}, ExtArgs["result"]["proclamation"]>
export type ProclamationSelectScalar = {
@@ -924,26 +924,26 @@ export type ProclamationSelectScalar = {
content?: boolean
solfeggioFrequencyId?: boolean
categoryId?: boolean
subscriptionLevelId?: boolean
groupId?: boolean
}
export type ProclamationOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "content" | "solfeggioFrequencyId" | "categoryId" | "subscriptionLevelId", ExtArgs["result"]["proclamation"]>
export type ProclamationOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "content" | "solfeggioFrequencyId" | "categoryId" | "groupId", ExtArgs["result"]["proclamation"]>
export type ProclamationInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
solfeggioFrequency?: boolean | Prisma.SolfeggioFrequencyDefaultArgs<ExtArgs>
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
subscriptionLevel?: boolean | Prisma.SubscriptionLevelDefaultArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
}
export type ProclamationIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
solfeggioFrequency?: boolean | Prisma.SolfeggioFrequencyDefaultArgs<ExtArgs>
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
subscriptionLevel?: boolean | Prisma.SubscriptionLevelDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
}
export type ProclamationIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
solfeggioFrequency?: boolean | Prisma.SolfeggioFrequencyDefaultArgs<ExtArgs>
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
subscriptionLevel?: boolean | Prisma.SubscriptionLevelDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
}
export type $ProclamationPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -951,8 +951,8 @@ export type $ProclamationPayload<ExtArgs extends runtime.Types.Extensions.Intern
objects: {
solfeggioFrequency: Prisma.$SolfeggioFrequencyPayload<ExtArgs>
category: Prisma.$ProclamationCategoryPayload<ExtArgs>
group: Prisma.$ProclamationGroupPayload<ExtArgs>
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
subscriptionLevel: Prisma.$SubscriptionLevelPayload<ExtArgs>
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
@@ -960,7 +960,7 @@ export type $ProclamationPayload<ExtArgs extends runtime.Types.Extensions.Intern
content: string
solfeggioFrequencyId: number
categoryId: number
subscriptionLevelId: number
groupId: number
}, ExtArgs["result"]["proclamation"]>
composites: {}
}
@@ -1357,8 +1357,8 @@ export interface Prisma__ProclamationClient<T, Null = never, ExtArgs extends run
readonly [Symbol.toStringTag]: "PrismaPromise"
solfeggioFrequency<T extends Prisma.SolfeggioFrequencyDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.SolfeggioFrequencyDefaultArgs<ExtArgs>>): Prisma.Prisma__SolfeggioFrequencyClient<runtime.Types.Result.GetResult<Prisma.$SolfeggioFrequencyPayload<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>
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>
subscriptionLevel<T extends Prisma.SubscriptionLevelDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.SubscriptionLevelDefaultArgs<ExtArgs>>): Prisma.Prisma__SubscriptionLevelClient<runtime.Types.Result.GetResult<Prisma.$SubscriptionLevelPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1393,7 +1393,7 @@ export interface ProclamationFieldRefs {
readonly content: Prisma.FieldRef<"Proclamation", 'String'>
readonly solfeggioFrequencyId: Prisma.FieldRef<"Proclamation", 'Int'>
readonly categoryId: Prisma.FieldRef<"Proclamation", 'Int'>
readonly subscriptionLevelId: Prisma.FieldRef<"Proclamation", 'Int'>
readonly groupId: Prisma.FieldRef<"Proclamation", 'Int'>
}
@@ -29,34 +29,29 @@ export type AggregateProclamationCategory = {
export type ProclamationCategoryAvgAggregateOutputType = {
id: number | null
sortOrder: number | null
parentId: number | null
}
export type ProclamationCategorySumAggregateOutputType = {
id: number | null
sortOrder: number | null
parentId: number | null
}
export type ProclamationCategoryMinAggregateOutputType = {
id: number | null
name: string | null
sortOrder: number | null
parentId: number | null
}
export type ProclamationCategoryMaxAggregateOutputType = {
id: number | null
name: string | null
sortOrder: number | null
parentId: number | null
}
export type ProclamationCategoryCountAggregateOutputType = {
id: number
name: number
sortOrder: number
parentId: number
_all: number
}
@@ -64,34 +59,29 @@ export type ProclamationCategoryCountAggregateOutputType = {
export type ProclamationCategoryAvgAggregateInputType = {
id?: true
sortOrder?: true
parentId?: true
}
export type ProclamationCategorySumAggregateInputType = {
id?: true
sortOrder?: true
parentId?: true
}
export type ProclamationCategoryMinAggregateInputType = {
id?: true
name?: true
sortOrder?: true
parentId?: true
}
export type ProclamationCategoryMaxAggregateInputType = {
id?: true
name?: true
sortOrder?: true
parentId?: true
}
export type ProclamationCategoryCountAggregateInputType = {
id?: true
name?: true
sortOrder?: true
parentId?: true
_all?: true
}
@@ -185,7 +175,6 @@ export type ProclamationCategoryGroupByOutputType = {
id: number
name: string
sortOrder: number
parentId: number | null
_count: ProclamationCategoryCountAggregateOutputType | null
_avg: ProclamationCategoryAvgAggregateOutputType | null
_sum: ProclamationCategorySumAggregateOutputType | null
@@ -215,44 +204,30 @@ export type ProclamationCategoryWhereInput = {
id?: Prisma.IntFilter<"ProclamationCategory"> | number
name?: Prisma.StringFilter<"ProclamationCategory"> | string
sortOrder?: Prisma.IntFilter<"ProclamationCategory"> | number
parentId?: Prisma.IntNullableFilter<"ProclamationCategory"> | number | null
parent?: Prisma.XOR<Prisma.ProclamationCategoryNullableScalarRelationFilter, Prisma.ProclamationCategoryWhereInput> | null
children?: Prisma.ProclamationCategoryListRelationFilter
proclamations?: Prisma.ProclamationListRelationFilter
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelListRelationFilter
}
export type ProclamationCategoryOrderByWithRelationInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
sortOrder?: Prisma.SortOrder
parentId?: Prisma.SortOrderInput | Prisma.SortOrder
parent?: Prisma.ProclamationCategoryOrderByWithRelationInput
children?: Prisma.ProclamationCategoryOrderByRelationAggregateInput
proclamations?: Prisma.ProclamationOrderByRelationAggregateInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelOrderByRelationAggregateInput
}
export type ProclamationCategoryWhereUniqueInput = Prisma.AtLeast<{
id?: number
parentId_name?: Prisma.ProclamationCategoryParentIdNameCompoundUniqueInput
name?: string
AND?: Prisma.ProclamationCategoryWhereInput | Prisma.ProclamationCategoryWhereInput[]
OR?: Prisma.ProclamationCategoryWhereInput[]
NOT?: Prisma.ProclamationCategoryWhereInput | Prisma.ProclamationCategoryWhereInput[]
name?: Prisma.StringFilter<"ProclamationCategory"> | string
sortOrder?: Prisma.IntFilter<"ProclamationCategory"> | number
parentId?: Prisma.IntNullableFilter<"ProclamationCategory"> | number | null
parent?: Prisma.XOR<Prisma.ProclamationCategoryNullableScalarRelationFilter, Prisma.ProclamationCategoryWhereInput> | null
children?: Prisma.ProclamationCategoryListRelationFilter
proclamations?: Prisma.ProclamationListRelationFilter
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelListRelationFilter
}, "id" | "parentId_name">
}, "id" | "name">
export type ProclamationCategoryOrderByWithAggregationInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
sortOrder?: Prisma.SortOrder
parentId?: Prisma.SortOrderInput | Prisma.SortOrder
_count?: Prisma.ProclamationCategoryCountOrderByAggregateInput
_avg?: Prisma.ProclamationCategoryAvgOrderByAggregateInput
_max?: Prisma.ProclamationCategoryMaxOrderByAggregateInput
@@ -267,52 +242,38 @@ export type ProclamationCategoryScalarWhereWithAggregatesInput = {
id?: Prisma.IntWithAggregatesFilter<"ProclamationCategory"> | number
name?: Prisma.StringWithAggregatesFilter<"ProclamationCategory"> | string
sortOrder?: Prisma.IntWithAggregatesFilter<"ProclamationCategory"> | number
parentId?: Prisma.IntNullableWithAggregatesFilter<"ProclamationCategory"> | number | null
}
export type ProclamationCategoryCreateInput = {
name: string
sortOrder?: number
parent?: Prisma.ProclamationCategoryCreateNestedOneWithoutChildrenInput
children?: Prisma.ProclamationCategoryCreateNestedManyWithoutParentInput
proclamations?: Prisma.ProclamationCreateNestedManyWithoutCategoryInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelCreateNestedManyWithoutCategoryInput
}
export type ProclamationCategoryUncheckedCreateInput = {
id?: number
name: string
sortOrder?: number
parentId?: number | null
children?: Prisma.ProclamationCategoryUncheckedCreateNestedManyWithoutParentInput
proclamations?: Prisma.ProclamationUncheckedCreateNestedManyWithoutCategoryInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUncheckedCreateNestedManyWithoutCategoryInput
}
export type ProclamationCategoryUpdateInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
parent?: Prisma.ProclamationCategoryUpdateOneWithoutChildrenNestedInput
children?: Prisma.ProclamationCategoryUpdateManyWithoutParentNestedInput
proclamations?: Prisma.ProclamationUpdateManyWithoutCategoryNestedInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUpdateManyWithoutCategoryNestedInput
}
export type ProclamationCategoryUncheckedUpdateInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
parentId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
children?: Prisma.ProclamationCategoryUncheckedUpdateManyWithoutParentNestedInput
proclamations?: Prisma.ProclamationUncheckedUpdateManyWithoutCategoryNestedInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUncheckedUpdateManyWithoutCategoryNestedInput
}
export type ProclamationCategoryCreateManyInput = {
id?: number
name: string
sortOrder?: number
parentId?: number | null
}
export type ProclamationCategoryUpdateManyMutationInput = {
@@ -324,7 +285,6 @@ export type ProclamationCategoryUncheckedUpdateManyInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
parentId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
}
export type ProclamationCategoryScalarRelationFilter = {
@@ -332,57 +292,32 @@ export type ProclamationCategoryScalarRelationFilter = {
isNot?: Prisma.ProclamationCategoryWhereInput
}
export type ProclamationCategoryNullableScalarRelationFilter = {
is?: Prisma.ProclamationCategoryWhereInput | null
isNot?: Prisma.ProclamationCategoryWhereInput | null
}
export type ProclamationCategoryListRelationFilter = {
every?: Prisma.ProclamationCategoryWhereInput
some?: Prisma.ProclamationCategoryWhereInput
none?: Prisma.ProclamationCategoryWhereInput
}
export type ProclamationCategoryOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type ProclamationCategoryParentIdNameCompoundUniqueInput = {
parentId: number
name: string
}
export type ProclamationCategoryCountOrderByAggregateInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
sortOrder?: Prisma.SortOrder
parentId?: Prisma.SortOrder
}
export type ProclamationCategoryAvgOrderByAggregateInput = {
id?: Prisma.SortOrder
sortOrder?: Prisma.SortOrder
parentId?: Prisma.SortOrder
}
export type ProclamationCategoryMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
sortOrder?: Prisma.SortOrder
parentId?: Prisma.SortOrder
}
export type ProclamationCategoryMinOrderByAggregateInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
sortOrder?: Prisma.SortOrder
parentId?: Prisma.SortOrder
}
export type ProclamationCategorySumOrderByAggregateInput = {
id?: Prisma.SortOrder
sortOrder?: Prisma.SortOrder
parentId?: Prisma.SortOrder
}
export type ProclamationCategoryCreateNestedOneWithoutProclamationsInput = {
@@ -399,93 +334,15 @@ export type ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ProclamationCategoryUpdateToOneWithWhereWithoutProclamationsInput, Prisma.ProclamationCategoryUpdateWithoutProclamationsInput>, Prisma.ProclamationCategoryUncheckedUpdateWithoutProclamationsInput>
}
export type ProclamationCategoryCreateNestedOneWithoutChildrenInput = {
create?: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutChildrenInput, Prisma.ProclamationCategoryUncheckedCreateWithoutChildrenInput>
connectOrCreate?: Prisma.ProclamationCategoryCreateOrConnectWithoutChildrenInput
connect?: Prisma.ProclamationCategoryWhereUniqueInput
}
export type ProclamationCategoryCreateNestedManyWithoutParentInput = {
create?: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutParentInput, Prisma.ProclamationCategoryUncheckedCreateWithoutParentInput> | Prisma.ProclamationCategoryCreateWithoutParentInput[] | Prisma.ProclamationCategoryUncheckedCreateWithoutParentInput[]
connectOrCreate?: Prisma.ProclamationCategoryCreateOrConnectWithoutParentInput | Prisma.ProclamationCategoryCreateOrConnectWithoutParentInput[]
createMany?: Prisma.ProclamationCategoryCreateManyParentInputEnvelope
connect?: Prisma.ProclamationCategoryWhereUniqueInput | Prisma.ProclamationCategoryWhereUniqueInput[]
}
export type ProclamationCategoryUncheckedCreateNestedManyWithoutParentInput = {
create?: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutParentInput, Prisma.ProclamationCategoryUncheckedCreateWithoutParentInput> | Prisma.ProclamationCategoryCreateWithoutParentInput[] | Prisma.ProclamationCategoryUncheckedCreateWithoutParentInput[]
connectOrCreate?: Prisma.ProclamationCategoryCreateOrConnectWithoutParentInput | Prisma.ProclamationCategoryCreateOrConnectWithoutParentInput[]
createMany?: Prisma.ProclamationCategoryCreateManyParentInputEnvelope
connect?: Prisma.ProclamationCategoryWhereUniqueInput | Prisma.ProclamationCategoryWhereUniqueInput[]
}
export type ProclamationCategoryUpdateOneWithoutChildrenNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutChildrenInput, Prisma.ProclamationCategoryUncheckedCreateWithoutChildrenInput>
connectOrCreate?: Prisma.ProclamationCategoryCreateOrConnectWithoutChildrenInput
upsert?: Prisma.ProclamationCategoryUpsertWithoutChildrenInput
disconnect?: Prisma.ProclamationCategoryWhereInput | boolean
delete?: Prisma.ProclamationCategoryWhereInput | boolean
connect?: Prisma.ProclamationCategoryWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ProclamationCategoryUpdateToOneWithWhereWithoutChildrenInput, Prisma.ProclamationCategoryUpdateWithoutChildrenInput>, Prisma.ProclamationCategoryUncheckedUpdateWithoutChildrenInput>
}
export type ProclamationCategoryUpdateManyWithoutParentNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutParentInput, Prisma.ProclamationCategoryUncheckedCreateWithoutParentInput> | Prisma.ProclamationCategoryCreateWithoutParentInput[] | Prisma.ProclamationCategoryUncheckedCreateWithoutParentInput[]
connectOrCreate?: Prisma.ProclamationCategoryCreateOrConnectWithoutParentInput | Prisma.ProclamationCategoryCreateOrConnectWithoutParentInput[]
upsert?: Prisma.ProclamationCategoryUpsertWithWhereUniqueWithoutParentInput | Prisma.ProclamationCategoryUpsertWithWhereUniqueWithoutParentInput[]
createMany?: Prisma.ProclamationCategoryCreateManyParentInputEnvelope
set?: Prisma.ProclamationCategoryWhereUniqueInput | Prisma.ProclamationCategoryWhereUniqueInput[]
disconnect?: Prisma.ProclamationCategoryWhereUniqueInput | Prisma.ProclamationCategoryWhereUniqueInput[]
delete?: Prisma.ProclamationCategoryWhereUniqueInput | Prisma.ProclamationCategoryWhereUniqueInput[]
connect?: Prisma.ProclamationCategoryWhereUniqueInput | Prisma.ProclamationCategoryWhereUniqueInput[]
update?: Prisma.ProclamationCategoryUpdateWithWhereUniqueWithoutParentInput | Prisma.ProclamationCategoryUpdateWithWhereUniqueWithoutParentInput[]
updateMany?: Prisma.ProclamationCategoryUpdateManyWithWhereWithoutParentInput | Prisma.ProclamationCategoryUpdateManyWithWhereWithoutParentInput[]
deleteMany?: Prisma.ProclamationCategoryScalarWhereInput | Prisma.ProclamationCategoryScalarWhereInput[]
}
export type ProclamationCategoryUncheckedUpdateManyWithoutParentNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutParentInput, Prisma.ProclamationCategoryUncheckedCreateWithoutParentInput> | Prisma.ProclamationCategoryCreateWithoutParentInput[] | Prisma.ProclamationCategoryUncheckedCreateWithoutParentInput[]
connectOrCreate?: Prisma.ProclamationCategoryCreateOrConnectWithoutParentInput | Prisma.ProclamationCategoryCreateOrConnectWithoutParentInput[]
upsert?: Prisma.ProclamationCategoryUpsertWithWhereUniqueWithoutParentInput | Prisma.ProclamationCategoryUpsertWithWhereUniqueWithoutParentInput[]
createMany?: Prisma.ProclamationCategoryCreateManyParentInputEnvelope
set?: Prisma.ProclamationCategoryWhereUniqueInput | Prisma.ProclamationCategoryWhereUniqueInput[]
disconnect?: Prisma.ProclamationCategoryWhereUniqueInput | Prisma.ProclamationCategoryWhereUniqueInput[]
delete?: Prisma.ProclamationCategoryWhereUniqueInput | Prisma.ProclamationCategoryWhereUniqueInput[]
connect?: Prisma.ProclamationCategoryWhereUniqueInput | Prisma.ProclamationCategoryWhereUniqueInput[]
update?: Prisma.ProclamationCategoryUpdateWithWhereUniqueWithoutParentInput | Prisma.ProclamationCategoryUpdateWithWhereUniqueWithoutParentInput[]
updateMany?: Prisma.ProclamationCategoryUpdateManyWithWhereWithoutParentInput | Prisma.ProclamationCategoryUpdateManyWithWhereWithoutParentInput[]
deleteMany?: Prisma.ProclamationCategoryScalarWhereInput | Prisma.ProclamationCategoryScalarWhereInput[]
}
export type ProclamationCategoryCreateNestedOneWithoutSubscriptionLevelsInput = {
create?: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutSubscriptionLevelsInput, Prisma.ProclamationCategoryUncheckedCreateWithoutSubscriptionLevelsInput>
connectOrCreate?: Prisma.ProclamationCategoryCreateOrConnectWithoutSubscriptionLevelsInput
connect?: Prisma.ProclamationCategoryWhereUniqueInput
}
export type ProclamationCategoryUpdateOneRequiredWithoutSubscriptionLevelsNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutSubscriptionLevelsInput, Prisma.ProclamationCategoryUncheckedCreateWithoutSubscriptionLevelsInput>
connectOrCreate?: Prisma.ProclamationCategoryCreateOrConnectWithoutSubscriptionLevelsInput
upsert?: Prisma.ProclamationCategoryUpsertWithoutSubscriptionLevelsInput
connect?: Prisma.ProclamationCategoryWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ProclamationCategoryUpdateToOneWithWhereWithoutSubscriptionLevelsInput, Prisma.ProclamationCategoryUpdateWithoutSubscriptionLevelsInput>, Prisma.ProclamationCategoryUncheckedUpdateWithoutSubscriptionLevelsInput>
}
export type ProclamationCategoryCreateWithoutProclamationsInput = {
name: string
sortOrder?: number
parent?: Prisma.ProclamationCategoryCreateNestedOneWithoutChildrenInput
children?: Prisma.ProclamationCategoryCreateNestedManyWithoutParentInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelCreateNestedManyWithoutCategoryInput
}
export type ProclamationCategoryUncheckedCreateWithoutProclamationsInput = {
id?: number
name: string
sortOrder?: number
parentId?: number | null
children?: Prisma.ProclamationCategoryUncheckedCreateNestedManyWithoutParentInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUncheckedCreateNestedManyWithoutCategoryInput
}
export type ProclamationCategoryCreateOrConnectWithoutProclamationsInput = {
@@ -507,200 +364,12 @@ export type ProclamationCategoryUpdateToOneWithWhereWithoutProclamationsInput =
export type ProclamationCategoryUpdateWithoutProclamationsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
parent?: Prisma.ProclamationCategoryUpdateOneWithoutChildrenNestedInput
children?: Prisma.ProclamationCategoryUpdateManyWithoutParentNestedInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUpdateManyWithoutCategoryNestedInput
}
export type ProclamationCategoryUncheckedUpdateWithoutProclamationsInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
parentId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
children?: Prisma.ProclamationCategoryUncheckedUpdateManyWithoutParentNestedInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUncheckedUpdateManyWithoutCategoryNestedInput
}
export type ProclamationCategoryCreateWithoutChildrenInput = {
name: string
sortOrder?: number
parent?: Prisma.ProclamationCategoryCreateNestedOneWithoutChildrenInput
proclamations?: Prisma.ProclamationCreateNestedManyWithoutCategoryInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelCreateNestedManyWithoutCategoryInput
}
export type ProclamationCategoryUncheckedCreateWithoutChildrenInput = {
id?: number
name: string
sortOrder?: number
parentId?: number | null
proclamations?: Prisma.ProclamationUncheckedCreateNestedManyWithoutCategoryInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUncheckedCreateNestedManyWithoutCategoryInput
}
export type ProclamationCategoryCreateOrConnectWithoutChildrenInput = {
where: Prisma.ProclamationCategoryWhereUniqueInput
create: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutChildrenInput, Prisma.ProclamationCategoryUncheckedCreateWithoutChildrenInput>
}
export type ProclamationCategoryCreateWithoutParentInput = {
name: string
sortOrder?: number
children?: Prisma.ProclamationCategoryCreateNestedManyWithoutParentInput
proclamations?: Prisma.ProclamationCreateNestedManyWithoutCategoryInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelCreateNestedManyWithoutCategoryInput
}
export type ProclamationCategoryUncheckedCreateWithoutParentInput = {
id?: number
name: string
sortOrder?: number
children?: Prisma.ProclamationCategoryUncheckedCreateNestedManyWithoutParentInput
proclamations?: Prisma.ProclamationUncheckedCreateNestedManyWithoutCategoryInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUncheckedCreateNestedManyWithoutCategoryInput
}
export type ProclamationCategoryCreateOrConnectWithoutParentInput = {
where: Prisma.ProclamationCategoryWhereUniqueInput
create: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutParentInput, Prisma.ProclamationCategoryUncheckedCreateWithoutParentInput>
}
export type ProclamationCategoryCreateManyParentInputEnvelope = {
data: Prisma.ProclamationCategoryCreateManyParentInput | Prisma.ProclamationCategoryCreateManyParentInput[]
skipDuplicates?: boolean
}
export type ProclamationCategoryUpsertWithoutChildrenInput = {
update: Prisma.XOR<Prisma.ProclamationCategoryUpdateWithoutChildrenInput, Prisma.ProclamationCategoryUncheckedUpdateWithoutChildrenInput>
create: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutChildrenInput, Prisma.ProclamationCategoryUncheckedCreateWithoutChildrenInput>
where?: Prisma.ProclamationCategoryWhereInput
}
export type ProclamationCategoryUpdateToOneWithWhereWithoutChildrenInput = {
where?: Prisma.ProclamationCategoryWhereInput
data: Prisma.XOR<Prisma.ProclamationCategoryUpdateWithoutChildrenInput, Prisma.ProclamationCategoryUncheckedUpdateWithoutChildrenInput>
}
export type ProclamationCategoryUpdateWithoutChildrenInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
parent?: Prisma.ProclamationCategoryUpdateOneWithoutChildrenNestedInput
proclamations?: Prisma.ProclamationUpdateManyWithoutCategoryNestedInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUpdateManyWithoutCategoryNestedInput
}
export type ProclamationCategoryUncheckedUpdateWithoutChildrenInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
parentId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
proclamations?: Prisma.ProclamationUncheckedUpdateManyWithoutCategoryNestedInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUncheckedUpdateManyWithoutCategoryNestedInput
}
export type ProclamationCategoryUpsertWithWhereUniqueWithoutParentInput = {
where: Prisma.ProclamationCategoryWhereUniqueInput
update: Prisma.XOR<Prisma.ProclamationCategoryUpdateWithoutParentInput, Prisma.ProclamationCategoryUncheckedUpdateWithoutParentInput>
create: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutParentInput, Prisma.ProclamationCategoryUncheckedCreateWithoutParentInput>
}
export type ProclamationCategoryUpdateWithWhereUniqueWithoutParentInput = {
where: Prisma.ProclamationCategoryWhereUniqueInput
data: Prisma.XOR<Prisma.ProclamationCategoryUpdateWithoutParentInput, Prisma.ProclamationCategoryUncheckedUpdateWithoutParentInput>
}
export type ProclamationCategoryUpdateManyWithWhereWithoutParentInput = {
where: Prisma.ProclamationCategoryScalarWhereInput
data: Prisma.XOR<Prisma.ProclamationCategoryUpdateManyMutationInput, Prisma.ProclamationCategoryUncheckedUpdateManyWithoutParentInput>
}
export type ProclamationCategoryScalarWhereInput = {
AND?: Prisma.ProclamationCategoryScalarWhereInput | Prisma.ProclamationCategoryScalarWhereInput[]
OR?: Prisma.ProclamationCategoryScalarWhereInput[]
NOT?: Prisma.ProclamationCategoryScalarWhereInput | Prisma.ProclamationCategoryScalarWhereInput[]
id?: Prisma.IntFilter<"ProclamationCategory"> | number
name?: Prisma.StringFilter<"ProclamationCategory"> | string
sortOrder?: Prisma.IntFilter<"ProclamationCategory"> | number
parentId?: Prisma.IntNullableFilter<"ProclamationCategory"> | number | null
}
export type ProclamationCategoryCreateWithoutSubscriptionLevelsInput = {
name: string
sortOrder?: number
parent?: Prisma.ProclamationCategoryCreateNestedOneWithoutChildrenInput
children?: Prisma.ProclamationCategoryCreateNestedManyWithoutParentInput
proclamations?: Prisma.ProclamationCreateNestedManyWithoutCategoryInput
}
export type ProclamationCategoryUncheckedCreateWithoutSubscriptionLevelsInput = {
id?: number
name: string
sortOrder?: number
parentId?: number | null
children?: Prisma.ProclamationCategoryUncheckedCreateNestedManyWithoutParentInput
proclamations?: Prisma.ProclamationUncheckedCreateNestedManyWithoutCategoryInput
}
export type ProclamationCategoryCreateOrConnectWithoutSubscriptionLevelsInput = {
where: Prisma.ProclamationCategoryWhereUniqueInput
create: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutSubscriptionLevelsInput, Prisma.ProclamationCategoryUncheckedCreateWithoutSubscriptionLevelsInput>
}
export type ProclamationCategoryUpsertWithoutSubscriptionLevelsInput = {
update: Prisma.XOR<Prisma.ProclamationCategoryUpdateWithoutSubscriptionLevelsInput, Prisma.ProclamationCategoryUncheckedUpdateWithoutSubscriptionLevelsInput>
create: Prisma.XOR<Prisma.ProclamationCategoryCreateWithoutSubscriptionLevelsInput, Prisma.ProclamationCategoryUncheckedCreateWithoutSubscriptionLevelsInput>
where?: Prisma.ProclamationCategoryWhereInput
}
export type ProclamationCategoryUpdateToOneWithWhereWithoutSubscriptionLevelsInput = {
where?: Prisma.ProclamationCategoryWhereInput
data: Prisma.XOR<Prisma.ProclamationCategoryUpdateWithoutSubscriptionLevelsInput, Prisma.ProclamationCategoryUncheckedUpdateWithoutSubscriptionLevelsInput>
}
export type ProclamationCategoryUpdateWithoutSubscriptionLevelsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
parent?: Prisma.ProclamationCategoryUpdateOneWithoutChildrenNestedInput
children?: Prisma.ProclamationCategoryUpdateManyWithoutParentNestedInput
proclamations?: Prisma.ProclamationUpdateManyWithoutCategoryNestedInput
}
export type ProclamationCategoryUncheckedUpdateWithoutSubscriptionLevelsInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
parentId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
children?: Prisma.ProclamationCategoryUncheckedUpdateManyWithoutParentNestedInput
proclamations?: Prisma.ProclamationUncheckedUpdateManyWithoutCategoryNestedInput
}
export type ProclamationCategoryCreateManyParentInput = {
id?: number
name: string
sortOrder?: number
}
export type ProclamationCategoryUpdateWithoutParentInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
children?: Prisma.ProclamationCategoryUpdateManyWithoutParentNestedInput
proclamations?: Prisma.ProclamationUpdateManyWithoutCategoryNestedInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUpdateManyWithoutCategoryNestedInput
}
export type ProclamationCategoryUncheckedUpdateWithoutParentInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
children?: Prisma.ProclamationCategoryUncheckedUpdateManyWithoutParentNestedInput
proclamations?: Prisma.ProclamationUncheckedUpdateManyWithoutCategoryNestedInput
subscriptionLevels?: Prisma.ProclamationCategorySubscriptionLevelUncheckedUpdateManyWithoutCategoryNestedInput
}
export type ProclamationCategoryUncheckedUpdateManyWithoutParentInput = {
id?: Prisma.IntFieldUpdateOperationsInput | number
name?: Prisma.StringFieldUpdateOperationsInput | string
sortOrder?: Prisma.IntFieldUpdateOperationsInput | number
}
@@ -709,15 +378,11 @@ export type ProclamationCategoryUncheckedUpdateManyWithoutParentInput = {
*/
export type ProclamationCategoryCountOutputType = {
children: number
proclamations: number
subscriptionLevels: number
}
export type ProclamationCategoryCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
children?: boolean | ProclamationCategoryCountOutputTypeCountChildrenArgs
proclamations?: boolean | ProclamationCategoryCountOutputTypeCountProclamationsArgs
subscriptionLevels?: boolean | ProclamationCategoryCountOutputTypeCountSubscriptionLevelsArgs
}
/**
@@ -730,13 +395,6 @@ export type ProclamationCategoryCountOutputTypeDefaultArgs<ExtArgs extends runti
select?: Prisma.ProclamationCategoryCountOutputTypeSelect<ExtArgs> | null
}
/**
* ProclamationCategoryCountOutputType without action
*/
export type ProclamationCategoryCountOutputTypeCountChildrenArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.ProclamationCategoryWhereInput
}
/**
* ProclamationCategoryCountOutputType without action
*/
@@ -744,23 +402,12 @@ export type ProclamationCategoryCountOutputTypeCountProclamationsArgs<ExtArgs ex
where?: Prisma.ProclamationWhereInput
}
/**
* ProclamationCategoryCountOutputType without action
*/
export type ProclamationCategoryCountOutputTypeCountSubscriptionLevelsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.ProclamationCategorySubscriptionLevelWhereInput
}
export type ProclamationCategorySelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
name?: boolean
sortOrder?: boolean
parentId?: boolean
parent?: boolean | Prisma.ProclamationCategory$parentArgs<ExtArgs>
children?: boolean | Prisma.ProclamationCategory$childrenArgs<ExtArgs>
proclamations?: boolean | Prisma.ProclamationCategory$proclamationsArgs<ExtArgs>
subscriptionLevels?: boolean | Prisma.ProclamationCategory$subscriptionLevelsArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCategoryCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["proclamationCategory"]>
@@ -768,53 +415,37 @@ export type ProclamationCategorySelectCreateManyAndReturn<ExtArgs extends runtim
id?: boolean
name?: boolean
sortOrder?: boolean
parentId?: boolean
parent?: boolean | Prisma.ProclamationCategory$parentArgs<ExtArgs>
}, ExtArgs["result"]["proclamationCategory"]>
export type ProclamationCategorySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
name?: boolean
sortOrder?: boolean
parentId?: boolean
parent?: boolean | Prisma.ProclamationCategory$parentArgs<ExtArgs>
}, ExtArgs["result"]["proclamationCategory"]>
export type ProclamationCategorySelectScalar = {
id?: boolean
name?: boolean
sortOrder?: boolean
parentId?: boolean
}
export type ProclamationCategoryOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "sortOrder" | "parentId", ExtArgs["result"]["proclamationCategory"]>
export type ProclamationCategoryOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "sortOrder", ExtArgs["result"]["proclamationCategory"]>
export type ProclamationCategoryInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
parent?: boolean | Prisma.ProclamationCategory$parentArgs<ExtArgs>
children?: boolean | Prisma.ProclamationCategory$childrenArgs<ExtArgs>
proclamations?: boolean | Prisma.ProclamationCategory$proclamationsArgs<ExtArgs>
subscriptionLevels?: boolean | Prisma.ProclamationCategory$subscriptionLevelsArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCategoryCountOutputTypeDefaultArgs<ExtArgs>
}
export type ProclamationCategoryIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
parent?: boolean | Prisma.ProclamationCategory$parentArgs<ExtArgs>
}
export type ProclamationCategoryIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
parent?: boolean | Prisma.ProclamationCategory$parentArgs<ExtArgs>
}
export type ProclamationCategoryIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
export type ProclamationCategoryIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {}
export type $ProclamationCategoryPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "ProclamationCategory"
objects: {
parent: Prisma.$ProclamationCategoryPayload<ExtArgs> | null
children: Prisma.$ProclamationCategoryPayload<ExtArgs>[]
proclamations: Prisma.$ProclamationPayload<ExtArgs>[]
subscriptionLevels: Prisma.$ProclamationCategorySubscriptionLevelPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: number
name: string
sortOrder: number
parentId: number | null
}, ExtArgs["result"]["proclamationCategory"]>
composites: {}
}
@@ -1209,10 +840,7 @@ readonly fields: ProclamationCategoryFieldRefs;
*/
export interface Prisma__ProclamationCategoryClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
parent<T extends Prisma.ProclamationCategory$parentArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationCategory$parentArgs<ExtArgs>>): Prisma.Prisma__ProclamationCategoryClient<runtime.Types.Result.GetResult<Prisma.$ProclamationCategoryPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
children<T extends Prisma.ProclamationCategory$childrenArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationCategory$childrenArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProclamationCategoryPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
proclamations<T extends Prisma.ProclamationCategory$proclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationCategory$proclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProclamationPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
subscriptionLevels<T extends Prisma.ProclamationCategory$subscriptionLevelsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationCategory$subscriptionLevelsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProclamationCategorySubscriptionLevelPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1245,7 +873,6 @@ export interface ProclamationCategoryFieldRefs {
readonly id: Prisma.FieldRef<"ProclamationCategory", 'Int'>
readonly name: Prisma.FieldRef<"ProclamationCategory", 'String'>
readonly sortOrder: Prisma.FieldRef<"ProclamationCategory", 'Int'>
readonly parentId: Prisma.FieldRef<"ProclamationCategory", 'Int'>
}
@@ -1500,10 +1127,6 @@ export type ProclamationCategoryCreateManyAndReturnArgs<ExtArgs extends runtime.
*/
data: Prisma.ProclamationCategoryCreateManyInput | Prisma.ProclamationCategoryCreateManyInput[]
skipDuplicates?: boolean
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ProclamationCategoryIncludeCreateManyAndReturn<ExtArgs> | null
}
/**
@@ -1574,10 +1197,6 @@ export type ProclamationCategoryUpdateManyAndReturnArgs<ExtArgs extends runtime.
* Limit how many ProclamationCategories to update.
*/
limit?: number
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ProclamationCategoryIncludeUpdateManyAndReturn<ExtArgs> | null
}
/**
@@ -1646,49 +1265,6 @@ export type ProclamationCategoryDeleteManyArgs<ExtArgs extends runtime.Types.Ext
limit?: number
}
/**
* ProclamationCategory.parent
*/
export type ProclamationCategory$parentArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the ProclamationCategory
*/
select?: Prisma.ProclamationCategorySelect<ExtArgs> | null
/**
* Omit specific fields from the ProclamationCategory
*/
omit?: Prisma.ProclamationCategoryOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ProclamationCategoryInclude<ExtArgs> | null
where?: Prisma.ProclamationCategoryWhereInput
}
/**
* ProclamationCategory.children
*/
export type ProclamationCategory$childrenArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the ProclamationCategory
*/
select?: Prisma.ProclamationCategorySelect<ExtArgs> | null
/**
* Omit specific fields from the ProclamationCategory
*/
omit?: Prisma.ProclamationCategoryOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ProclamationCategoryInclude<ExtArgs> | null
where?: Prisma.ProclamationCategoryWhereInput
orderBy?: Prisma.ProclamationCategoryOrderByWithRelationInput | Prisma.ProclamationCategoryOrderByWithRelationInput[]
cursor?: Prisma.ProclamationCategoryWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.ProclamationCategoryScalarFieldEnum | Prisma.ProclamationCategoryScalarFieldEnum[]
}
/**
* ProclamationCategory.proclamations
*/
@@ -1713,30 +1289,6 @@ export type ProclamationCategory$proclamationsArgs<ExtArgs extends runtime.Types
distinct?: Prisma.ProclamationScalarFieldEnum | Prisma.ProclamationScalarFieldEnum[]
}
/**
* ProclamationCategory.subscriptionLevels
*/
export type ProclamationCategory$subscriptionLevelsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the ProclamationCategorySubscriptionLevel
*/
select?: Prisma.ProclamationCategorySubscriptionLevelSelect<ExtArgs> | null
/**
* Omit specific fields from the ProclamationCategorySubscriptionLevel
*/
omit?: Prisma.ProclamationCategorySubscriptionLevelOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ProclamationCategorySubscriptionLevelInclude<ExtArgs> | null
where?: Prisma.ProclamationCategorySubscriptionLevelWhereInput
orderBy?: Prisma.ProclamationCategorySubscriptionLevelOrderByWithRelationInput | Prisma.ProclamationCategorySubscriptionLevelOrderByWithRelationInput[]
cursor?: Prisma.ProclamationCategorySubscriptionLevelWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.ProclamationCategorySubscriptionLevelScalarFieldEnum | Prisma.ProclamationCategorySubscriptionLevelScalarFieldEnum[]
}
/**
* ProclamationCategory without action
*/
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+187 -76
View File
@@ -27,6 +27,7 @@ export type AggregateProfile = {
export type ProfileMinAggregateOutputType = {
id: string | null
userId: string | null
email: string | null
firstName: string | null
lastName: string | null
phoneNumber: string | null
@@ -36,6 +37,7 @@ export type ProfileMinAggregateOutputType = {
export type ProfileMaxAggregateOutputType = {
id: string | null
userId: string | null
email: string | null
firstName: string | null
lastName: string | null
phoneNumber: string | null
@@ -45,6 +47,7 @@ export type ProfileMaxAggregateOutputType = {
export type ProfileCountAggregateOutputType = {
id: number
userId: number
email: number
firstName: number
lastName: number
phoneNumber: number
@@ -56,6 +59,7 @@ export type ProfileCountAggregateOutputType = {
export type ProfileMinAggregateInputType = {
id?: true
userId?: true
email?: true
firstName?: true
lastName?: true
phoneNumber?: true
@@ -65,6 +69,7 @@ export type ProfileMinAggregateInputType = {
export type ProfileMaxAggregateInputType = {
id?: true
userId?: true
email?: true
firstName?: true
lastName?: true
phoneNumber?: true
@@ -74,6 +79,7 @@ export type ProfileMaxAggregateInputType = {
export type ProfileCountAggregateInputType = {
id?: true
userId?: true
email?: true
firstName?: true
lastName?: true
phoneNumber?: true
@@ -155,7 +161,8 @@ export type ProfileGroupByArgs<ExtArgs extends runtime.Types.Extensions.Internal
export type ProfileGroupByOutputType = {
id: string
userId: string
userId: string | null
email: string | null
firstName: string
lastName: string
phoneNumber: string
@@ -185,18 +192,20 @@ export type ProfileWhereInput = {
OR?: Prisma.ProfileWhereInput[]
NOT?: Prisma.ProfileWhereInput | Prisma.ProfileWhereInput[]
id?: Prisma.StringFilter<"Profile"> | string
userId?: Prisma.StringFilter<"Profile"> | string
userId?: Prisma.StringNullableFilter<"Profile"> | string | null
email?: Prisma.StringNullableFilter<"Profile"> | string | null
firstName?: Prisma.StringFilter<"Profile"> | string
lastName?: Prisma.StringFilter<"Profile"> | string
phoneNumber?: Prisma.StringFilter<"Profile"> | string
ghlContactId?: Prisma.StringNullableFilter<"Profile"> | string | null
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
addresses?: Prisma.AddressListRelationFilter
}
export type ProfileOrderByWithRelationInput = {
id?: Prisma.SortOrder
userId?: Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder
email?: Prisma.SortOrderInput | Prisma.SortOrder
firstName?: Prisma.SortOrder
lastName?: Prisma.SortOrder
phoneNumber?: Prisma.SortOrder
@@ -207,21 +216,23 @@ export type ProfileOrderByWithRelationInput = {
export type ProfileWhereUniqueInput = Prisma.AtLeast<{
id?: string
userId?: string
AND?: Prisma.ProfileWhereInput | Prisma.ProfileWhereInput[]
OR?: Prisma.ProfileWhereInput[]
NOT?: Prisma.ProfileWhereInput | Prisma.ProfileWhereInput[]
userId?: Prisma.StringNullableFilter<"Profile"> | string | null
email?: Prisma.StringNullableFilter<"Profile"> | string | null
firstName?: Prisma.StringFilter<"Profile"> | string
lastName?: Prisma.StringFilter<"Profile"> | string
phoneNumber?: Prisma.StringFilter<"Profile"> | string
ghlContactId?: Prisma.StringNullableFilter<"Profile"> | string | null
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
addresses?: Prisma.AddressListRelationFilter
}, "id" | "userId">
}, "id">
export type ProfileOrderByWithAggregationInput = {
id?: Prisma.SortOrder
userId?: Prisma.SortOrder
userId?: Prisma.SortOrderInput | Prisma.SortOrder
email?: Prisma.SortOrderInput | Prisma.SortOrder
firstName?: Prisma.SortOrder
lastName?: Prisma.SortOrder
phoneNumber?: Prisma.SortOrder
@@ -236,7 +247,8 @@ export type ProfileScalarWhereWithAggregatesInput = {
OR?: Prisma.ProfileScalarWhereWithAggregatesInput[]
NOT?: Prisma.ProfileScalarWhereWithAggregatesInput | Prisma.ProfileScalarWhereWithAggregatesInput[]
id?: Prisma.StringWithAggregatesFilter<"Profile"> | string
userId?: Prisma.StringWithAggregatesFilter<"Profile"> | string
userId?: Prisma.StringNullableWithAggregatesFilter<"Profile"> | string | null
email?: Prisma.StringNullableWithAggregatesFilter<"Profile"> | string | null
firstName?: Prisma.StringWithAggregatesFilter<"Profile"> | string
lastName?: Prisma.StringWithAggregatesFilter<"Profile"> | string
phoneNumber?: Prisma.StringWithAggregatesFilter<"Profile"> | string
@@ -245,17 +257,19 @@ export type ProfileScalarWhereWithAggregatesInput = {
export type ProfileCreateInput = {
id?: string
email?: string | null
firstName: string
lastName: string
phoneNumber: string
ghlContactId?: string | null
user: Prisma.UserCreateNestedOneWithoutProfileInput
user?: Prisma.UserCreateNestedOneWithoutProfilesInput
addresses?: Prisma.AddressCreateNestedManyWithoutProfileInput
}
export type ProfileUncheckedCreateInput = {
id?: string
userId: string
userId?: string | null
email?: string | null
firstName: string
lastName: string
phoneNumber: string
@@ -265,17 +279,19 @@ export type ProfileUncheckedCreateInput = {
export type ProfileUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
ghlContactId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
user?: Prisma.UserUpdateOneRequiredWithoutProfileNestedInput
user?: Prisma.UserUpdateOneWithoutProfilesNestedInput
addresses?: Prisma.AddressUpdateManyWithoutProfileNestedInput
}
export type ProfileUncheckedUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
@@ -285,7 +301,8 @@ export type ProfileUncheckedUpdateInput = {
export type ProfileCreateManyInput = {
id?: string
userId: string
userId?: string | null
email?: string | null
firstName: string
lastName: string
phoneNumber: string
@@ -294,6 +311,7 @@ export type ProfileCreateManyInput = {
export type ProfileUpdateManyMutationInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
@@ -302,21 +320,28 @@ export type ProfileUpdateManyMutationInput = {
export type ProfileUncheckedUpdateManyInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
ghlContactId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type ProfileNullableScalarRelationFilter = {
is?: Prisma.ProfileWhereInput | null
isNot?: Prisma.ProfileWhereInput | null
export type ProfileListRelationFilter = {
every?: Prisma.ProfileWhereInput
some?: Prisma.ProfileWhereInput
none?: Prisma.ProfileWhereInput
}
export type ProfileOrderByRelationAggregateInput = {
_count?: Prisma.SortOrder
}
export type ProfileCountOrderByAggregateInput = {
id?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
firstName?: Prisma.SortOrder
lastName?: Prisma.SortOrder
phoneNumber?: Prisma.SortOrder
@@ -326,6 +351,7 @@ export type ProfileCountOrderByAggregateInput = {
export type ProfileMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
firstName?: Prisma.SortOrder
lastName?: Prisma.SortOrder
phoneNumber?: Prisma.SortOrder
@@ -335,6 +361,7 @@ export type ProfileMaxOrderByAggregateInput = {
export type ProfileMinOrderByAggregateInput = {
id?: Prisma.SortOrder
userId?: Prisma.SortOrder
email?: Prisma.SortOrder
firstName?: Prisma.SortOrder
lastName?: Prisma.SortOrder
phoneNumber?: Prisma.SortOrder
@@ -346,36 +373,46 @@ export type ProfileScalarRelationFilter = {
isNot?: Prisma.ProfileWhereInput
}
export type ProfileCreateNestedOneWithoutUserInput = {
create?: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput>
connectOrCreate?: Prisma.ProfileCreateOrConnectWithoutUserInput
connect?: Prisma.ProfileWhereUniqueInput
export type ProfileCreateNestedManyWithoutUserInput = {
create?: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput> | Prisma.ProfileCreateWithoutUserInput[] | Prisma.ProfileUncheckedCreateWithoutUserInput[]
connectOrCreate?: Prisma.ProfileCreateOrConnectWithoutUserInput | Prisma.ProfileCreateOrConnectWithoutUserInput[]
createMany?: Prisma.ProfileCreateManyUserInputEnvelope
connect?: Prisma.ProfileWhereUniqueInput | Prisma.ProfileWhereUniqueInput[]
}
export type ProfileUncheckedCreateNestedOneWithoutUserInput = {
create?: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput>
connectOrCreate?: Prisma.ProfileCreateOrConnectWithoutUserInput
connect?: Prisma.ProfileWhereUniqueInput
export type ProfileUncheckedCreateNestedManyWithoutUserInput = {
create?: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput> | Prisma.ProfileCreateWithoutUserInput[] | Prisma.ProfileUncheckedCreateWithoutUserInput[]
connectOrCreate?: Prisma.ProfileCreateOrConnectWithoutUserInput | Prisma.ProfileCreateOrConnectWithoutUserInput[]
createMany?: Prisma.ProfileCreateManyUserInputEnvelope
connect?: Prisma.ProfileWhereUniqueInput | Prisma.ProfileWhereUniqueInput[]
}
export type ProfileUpdateOneWithoutUserNestedInput = {
create?: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput>
connectOrCreate?: Prisma.ProfileCreateOrConnectWithoutUserInput
upsert?: Prisma.ProfileUpsertWithoutUserInput
disconnect?: Prisma.ProfileWhereInput | boolean
delete?: Prisma.ProfileWhereInput | boolean
connect?: Prisma.ProfileWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ProfileUpdateToOneWithWhereWithoutUserInput, Prisma.ProfileUpdateWithoutUserInput>, Prisma.ProfileUncheckedUpdateWithoutUserInput>
export type ProfileUpdateManyWithoutUserNestedInput = {
create?: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput> | Prisma.ProfileCreateWithoutUserInput[] | Prisma.ProfileUncheckedCreateWithoutUserInput[]
connectOrCreate?: Prisma.ProfileCreateOrConnectWithoutUserInput | Prisma.ProfileCreateOrConnectWithoutUserInput[]
upsert?: Prisma.ProfileUpsertWithWhereUniqueWithoutUserInput | Prisma.ProfileUpsertWithWhereUniqueWithoutUserInput[]
createMany?: Prisma.ProfileCreateManyUserInputEnvelope
set?: Prisma.ProfileWhereUniqueInput | Prisma.ProfileWhereUniqueInput[]
disconnect?: Prisma.ProfileWhereUniqueInput | Prisma.ProfileWhereUniqueInput[]
delete?: Prisma.ProfileWhereUniqueInput | Prisma.ProfileWhereUniqueInput[]
connect?: Prisma.ProfileWhereUniqueInput | Prisma.ProfileWhereUniqueInput[]
update?: Prisma.ProfileUpdateWithWhereUniqueWithoutUserInput | Prisma.ProfileUpdateWithWhereUniqueWithoutUserInput[]
updateMany?: Prisma.ProfileUpdateManyWithWhereWithoutUserInput | Prisma.ProfileUpdateManyWithWhereWithoutUserInput[]
deleteMany?: Prisma.ProfileScalarWhereInput | Prisma.ProfileScalarWhereInput[]
}
export type ProfileUncheckedUpdateOneWithoutUserNestedInput = {
create?: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput>
connectOrCreate?: Prisma.ProfileCreateOrConnectWithoutUserInput
upsert?: Prisma.ProfileUpsertWithoutUserInput
disconnect?: Prisma.ProfileWhereInput | boolean
delete?: Prisma.ProfileWhereInput | boolean
connect?: Prisma.ProfileWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ProfileUpdateToOneWithWhereWithoutUserInput, Prisma.ProfileUpdateWithoutUserInput>, Prisma.ProfileUncheckedUpdateWithoutUserInput>
export type ProfileUncheckedUpdateManyWithoutUserNestedInput = {
create?: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput> | Prisma.ProfileCreateWithoutUserInput[] | Prisma.ProfileUncheckedCreateWithoutUserInput[]
connectOrCreate?: Prisma.ProfileCreateOrConnectWithoutUserInput | Prisma.ProfileCreateOrConnectWithoutUserInput[]
upsert?: Prisma.ProfileUpsertWithWhereUniqueWithoutUserInput | Prisma.ProfileUpsertWithWhereUniqueWithoutUserInput[]
createMany?: Prisma.ProfileCreateManyUserInputEnvelope
set?: Prisma.ProfileWhereUniqueInput | Prisma.ProfileWhereUniqueInput[]
disconnect?: Prisma.ProfileWhereUniqueInput | Prisma.ProfileWhereUniqueInput[]
delete?: Prisma.ProfileWhereUniqueInput | Prisma.ProfileWhereUniqueInput[]
connect?: Prisma.ProfileWhereUniqueInput | Prisma.ProfileWhereUniqueInput[]
update?: Prisma.ProfileUpdateWithWhereUniqueWithoutUserInput | Prisma.ProfileUpdateWithWhereUniqueWithoutUserInput[]
updateMany?: Prisma.ProfileUpdateManyWithWhereWithoutUserInput | Prisma.ProfileUpdateManyWithWhereWithoutUserInput[]
deleteMany?: Prisma.ProfileScalarWhereInput | Prisma.ProfileScalarWhereInput[]
}
export type ProfileCreateNestedOneWithoutAddressesInput = {
@@ -394,6 +431,7 @@ export type ProfileUpdateOneRequiredWithoutAddressesNestedInput = {
export type ProfileCreateWithoutUserInput = {
id?: string
email?: string | null
firstName: string
lastName: string
phoneNumber: string
@@ -403,6 +441,7 @@ export type ProfileCreateWithoutUserInput = {
export type ProfileUncheckedCreateWithoutUserInput = {
id?: string
email?: string | null
firstName: string
lastName: string
phoneNumber: string
@@ -415,47 +454,54 @@ export type ProfileCreateOrConnectWithoutUserInput = {
create: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput>
}
export type ProfileUpsertWithoutUserInput = {
update: Prisma.XOR<Prisma.ProfileUpdateWithoutUserInput, Prisma.ProfileUncheckedUpdateWithoutUserInput>
create: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput>
where?: Prisma.ProfileWhereInput
export type ProfileCreateManyUserInputEnvelope = {
data: Prisma.ProfileCreateManyUserInput | Prisma.ProfileCreateManyUserInput[]
skipDuplicates?: boolean
}
export type ProfileUpdateToOneWithWhereWithoutUserInput = {
where?: Prisma.ProfileWhereInput
export type ProfileUpsertWithWhereUniqueWithoutUserInput = {
where: Prisma.ProfileWhereUniqueInput
update: Prisma.XOR<Prisma.ProfileUpdateWithoutUserInput, Prisma.ProfileUncheckedUpdateWithoutUserInput>
create: Prisma.XOR<Prisma.ProfileCreateWithoutUserInput, Prisma.ProfileUncheckedCreateWithoutUserInput>
}
export type ProfileUpdateWithWhereUniqueWithoutUserInput = {
where: Prisma.ProfileWhereUniqueInput
data: Prisma.XOR<Prisma.ProfileUpdateWithoutUserInput, Prisma.ProfileUncheckedUpdateWithoutUserInput>
}
export type ProfileUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
ghlContactId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
addresses?: Prisma.AddressUpdateManyWithoutProfileNestedInput
export type ProfileUpdateManyWithWhereWithoutUserInput = {
where: Prisma.ProfileScalarWhereInput
data: Prisma.XOR<Prisma.ProfileUpdateManyMutationInput, Prisma.ProfileUncheckedUpdateManyWithoutUserInput>
}
export type ProfileUncheckedUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
ghlContactId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
addresses?: Prisma.AddressUncheckedUpdateManyWithoutProfileNestedInput
export type ProfileScalarWhereInput = {
AND?: Prisma.ProfileScalarWhereInput | Prisma.ProfileScalarWhereInput[]
OR?: Prisma.ProfileScalarWhereInput[]
NOT?: Prisma.ProfileScalarWhereInput | Prisma.ProfileScalarWhereInput[]
id?: Prisma.StringFilter<"Profile"> | string
userId?: Prisma.StringNullableFilter<"Profile"> | string | null
email?: Prisma.StringNullableFilter<"Profile"> | string | null
firstName?: Prisma.StringFilter<"Profile"> | string
lastName?: Prisma.StringFilter<"Profile"> | string
phoneNumber?: Prisma.StringFilter<"Profile"> | string
ghlContactId?: Prisma.StringNullableFilter<"Profile"> | string | null
}
export type ProfileCreateWithoutAddressesInput = {
id?: string
email?: string | null
firstName: string
lastName: string
phoneNumber: string
ghlContactId?: string | null
user: Prisma.UserCreateNestedOneWithoutProfileInput
user?: Prisma.UserCreateNestedOneWithoutProfilesInput
}
export type ProfileUncheckedCreateWithoutAddressesInput = {
id?: string
userId: string
userId?: string | null
email?: string | null
firstName: string
lastName: string
phoneNumber: string
@@ -480,16 +526,56 @@ export type ProfileUpdateToOneWithWhereWithoutAddressesInput = {
export type ProfileUpdateWithoutAddressesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
ghlContactId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
user?: Prisma.UserUpdateOneRequiredWithoutProfileNestedInput
user?: Prisma.UserUpdateOneWithoutProfilesNestedInput
}
export type ProfileUncheckedUpdateWithoutAddressesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.StringFieldUpdateOperationsInput | string
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
ghlContactId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
}
export type ProfileCreateManyUserInput = {
id?: string
email?: string | null
firstName: string
lastName: string
phoneNumber: string
ghlContactId?: string | null
}
export type ProfileUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
ghlContactId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
addresses?: Prisma.AddressUpdateManyWithoutProfileNestedInput
}
export type ProfileUncheckedUpdateWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
ghlContactId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
addresses?: Prisma.AddressUncheckedUpdateManyWithoutProfileNestedInput
}
export type ProfileUncheckedUpdateManyWithoutUserInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
firstName?: Prisma.StringFieldUpdateOperationsInput | string
lastName?: Prisma.StringFieldUpdateOperationsInput | string
phoneNumber?: Prisma.StringFieldUpdateOperationsInput | string
@@ -530,11 +616,12 @@ export type ProfileCountOutputTypeCountAddressesArgs<ExtArgs extends runtime.Typ
export type ProfileSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
userId?: boolean
email?: boolean
firstName?: boolean
lastName?: boolean
phoneNumber?: boolean
ghlContactId?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
user?: boolean | Prisma.Profile$userArgs<ExtArgs>
addresses?: boolean | Prisma.Profile$addressesArgs<ExtArgs>
_count?: boolean | Prisma.ProfileCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["profile"]>
@@ -542,54 +629,58 @@ export type ProfileSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
export type ProfileSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
userId?: boolean
email?: boolean
firstName?: boolean
lastName?: boolean
phoneNumber?: boolean
ghlContactId?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
user?: boolean | Prisma.Profile$userArgs<ExtArgs>
}, ExtArgs["result"]["profile"]>
export type ProfileSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean
userId?: boolean
email?: boolean
firstName?: boolean
lastName?: boolean
phoneNumber?: boolean
ghlContactId?: boolean
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
user?: boolean | Prisma.Profile$userArgs<ExtArgs>
}, ExtArgs["result"]["profile"]>
export type ProfileSelectScalar = {
id?: boolean
userId?: boolean
email?: boolean
firstName?: boolean
lastName?: boolean
phoneNumber?: boolean
ghlContactId?: boolean
}
export type ProfileOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "userId" | "firstName" | "lastName" | "phoneNumber" | "ghlContactId", ExtArgs["result"]["profile"]>
export type ProfileOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "userId" | "email" | "firstName" | "lastName" | "phoneNumber" | "ghlContactId", ExtArgs["result"]["profile"]>
export type ProfileInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
user?: boolean | Prisma.Profile$userArgs<ExtArgs>
addresses?: boolean | Prisma.Profile$addressesArgs<ExtArgs>
_count?: boolean | Prisma.ProfileCountOutputTypeDefaultArgs<ExtArgs>
}
export type ProfileIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
user?: boolean | Prisma.Profile$userArgs<ExtArgs>
}
export type ProfileIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
user?: boolean | Prisma.Profile$userArgs<ExtArgs>
}
export type $ProfilePayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
name: "Profile"
objects: {
user: Prisma.$UserPayload<ExtArgs>
user: Prisma.$UserPayload<ExtArgs> | null
addresses: Prisma.$AddressPayload<ExtArgs>[]
}
scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string
userId: string
userId: string | null
email: string | null
firstName: string
lastName: string
phoneNumber: string
@@ -988,7 +1079,7 @@ readonly fields: ProfileFieldRefs;
*/
export interface Prisma__ProfileClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
readonly [Symbol.toStringTag]: "PrismaPromise"
user<T extends Prisma.UserDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.UserDefaultArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
user<T extends Prisma.Profile$userArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Profile$userArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
addresses<T extends Prisma.Profile$addressesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Profile$addressesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$AddressPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
* Attaches callbacks for the resolution and/or rejection of the Promise.
@@ -1021,6 +1112,7 @@ export interface Prisma__ProfileClient<T, Null = never, ExtArgs extends runtime.
export interface ProfileFieldRefs {
readonly id: Prisma.FieldRef<"Profile", 'String'>
readonly userId: Prisma.FieldRef<"Profile", 'String'>
readonly email: Prisma.FieldRef<"Profile", 'String'>
readonly firstName: Prisma.FieldRef<"Profile", 'String'>
readonly lastName: Prisma.FieldRef<"Profile", 'String'>
readonly phoneNumber: Prisma.FieldRef<"Profile", 'String'>
@@ -1425,6 +1517,25 @@ export type ProfileDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Inter
limit?: number
}
/**
* Profile.user
*/
export type Profile$userArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the User
*/
select?: Prisma.UserSelect<ExtArgs> | null
/**
* Omit specific fields from the User
*/
omit?: Prisma.UserOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.UserInclude<ExtArgs> | null
where?: Prisma.UserWhereInput
}
/**
* Profile.addresses
*/
File diff suppressed because it is too large Load Diff
+76 -53
View File
@@ -243,7 +243,7 @@ export type UserWhereInput = {
role?: Prisma.XOR<Prisma.UserRoleNullableScalarRelationFilter, Prisma.UserRoleWhereInput> | null
sessions?: Prisma.SessionListRelationFilter
accounts?: Prisma.AccountListRelationFilter
profile?: Prisma.XOR<Prisma.ProfileNullableScalarRelationFilter, Prisma.ProfileWhereInput> | null
profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter
}
@@ -260,7 +260,7 @@ export type UserOrderByWithRelationInput = {
role?: Prisma.UserRoleOrderByWithRelationInput
sessions?: Prisma.SessionOrderByRelationAggregateInput
accounts?: Prisma.AccountOrderByRelationAggregateInput
profile?: Prisma.ProfileOrderByWithRelationInput
profiles?: Prisma.ProfileOrderByRelationAggregateInput
licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
}
@@ -280,7 +280,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
role?: Prisma.XOR<Prisma.UserRoleNullableScalarRelationFilter, Prisma.UserRoleWhereInput> | null
sessions?: Prisma.SessionListRelationFilter
accounts?: Prisma.AccountListRelationFilter
profile?: Prisma.XOR<Prisma.ProfileNullableScalarRelationFilter, Prisma.ProfileWhereInput> | null
profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter
}, "id" | "email">
@@ -326,7 +326,7 @@ export type UserCreateInput = {
role?: Prisma.UserRoleCreateNestedOneWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
@@ -342,7 +342,7 @@ export type UserUncheckedCreateInput = {
roleId?: number | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileUncheckedCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
@@ -358,7 +358,7 @@ export type UserUpdateInput = {
role?: Prisma.UserRoleUpdateOneWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
@@ -374,7 +374,7 @@ export type UserUncheckedUpdateInput = {
roleId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUncheckedUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
}
@@ -452,6 +452,11 @@ export type UserSumOrderByAggregateInput = {
roleId?: Prisma.SortOrder
}
export type UserNullableScalarRelationFilter = {
is?: Prisma.UserWhereInput | null
isNot?: Prisma.UserWhereInput | null
}
export type UserScalarRelationFilter = {
is?: Prisma.UserWhereInput
isNot?: Prisma.UserWhereInput
@@ -491,18 +496,20 @@ export type NullableIntFieldUpdateOperationsInput = {
divide?: number
}
export type UserCreateNestedOneWithoutProfileInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutProfileInput, Prisma.UserUncheckedCreateWithoutProfileInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutProfileInput
export type UserCreateNestedOneWithoutProfilesInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutProfilesInput, Prisma.UserUncheckedCreateWithoutProfilesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutProfilesInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutProfileNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutProfileInput, Prisma.UserUncheckedCreateWithoutProfileInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutProfileInput
upsert?: Prisma.UserUpsertWithoutProfileInput
export type UserUpdateOneWithoutProfilesNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutProfilesInput, Prisma.UserUncheckedCreateWithoutProfilesInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutProfilesInput
upsert?: Prisma.UserUpsertWithoutProfilesInput
disconnect?: Prisma.UserWhereInput | boolean
delete?: Prisma.UserWhereInput | boolean
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutProfileInput, Prisma.UserUpdateWithoutProfileInput>, Prisma.UserUncheckedUpdateWithoutProfileInput>
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutProfilesInput, Prisma.UserUpdateWithoutProfilesInput>, Prisma.UserUncheckedUpdateWithoutProfilesInput>
}
export type UserCreateNestedOneWithoutSessionsInput = {
@@ -595,15 +602,17 @@ export type UserCreateNestedOneWithoutLicenseKeysInput = {
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutLicenseKeysNestedInput = {
export type UserUpdateOneWithoutLicenseKeysNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutLicenseKeysInput, Prisma.UserUncheckedCreateWithoutLicenseKeysInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutLicenseKeysInput
upsert?: Prisma.UserUpsertWithoutLicenseKeysInput
disconnect?: Prisma.UserWhereInput | boolean
delete?: Prisma.UserWhereInput | boolean
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput>
}
export type UserCreateWithoutProfileInput = {
export type UserCreateWithoutProfilesInput = {
id: string
name: string
email: string
@@ -618,7 +627,7 @@ export type UserCreateWithoutProfileInput = {
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutProfileInput = {
export type UserUncheckedCreateWithoutProfilesInput = {
id: string
name: string
email: string
@@ -633,23 +642,23 @@ export type UserUncheckedCreateWithoutProfileInput = {
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutProfileInput = {
export type UserCreateOrConnectWithoutProfilesInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutProfileInput, Prisma.UserUncheckedCreateWithoutProfileInput>
create: Prisma.XOR<Prisma.UserCreateWithoutProfilesInput, Prisma.UserUncheckedCreateWithoutProfilesInput>
}
export type UserUpsertWithoutProfileInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutProfileInput, Prisma.UserUncheckedUpdateWithoutProfileInput>
create: Prisma.XOR<Prisma.UserCreateWithoutProfileInput, Prisma.UserUncheckedCreateWithoutProfileInput>
export type UserUpsertWithoutProfilesInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutProfilesInput, Prisma.UserUncheckedUpdateWithoutProfilesInput>
create: Prisma.XOR<Prisma.UserCreateWithoutProfilesInput, Prisma.UserUncheckedCreateWithoutProfilesInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutProfileInput = {
export type UserUpdateToOneWithWhereWithoutProfilesInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutProfileInput, Prisma.UserUncheckedUpdateWithoutProfileInput>
data: Prisma.XOR<Prisma.UserUpdateWithoutProfilesInput, Prisma.UserUncheckedUpdateWithoutProfilesInput>
}
export type UserUpdateWithoutProfileInput = {
export type UserUpdateWithoutProfilesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
@@ -664,7 +673,7 @@ export type UserUpdateWithoutProfileInput = {
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutProfileInput = {
export type UserUncheckedUpdateWithoutProfilesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
@@ -689,7 +698,7 @@ export type UserCreateWithoutSessionsInput = {
updatedAt?: Date | string
role?: Prisma.UserRoleCreateNestedOneWithoutUsersInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
@@ -704,7 +713,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
updatedAt?: Date | string
roleId?: number | null
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileUncheckedCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
@@ -735,7 +744,7 @@ export type UserUpdateWithoutSessionsInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.UserRoleUpdateOneWithoutUsersNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
@@ -750,7 +759,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roleId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUncheckedUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
}
@@ -765,7 +774,7 @@ export type UserCreateWithoutAccountsInput = {
updatedAt?: Date | string
role?: Prisma.UserRoleCreateNestedOneWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
@@ -780,7 +789,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
updatedAt?: Date | string
roleId?: number | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileUncheckedCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
@@ -811,7 +820,7 @@ export type UserUpdateWithoutAccountsInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.UserRoleUpdateOneWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
@@ -826,7 +835,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roleId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUncheckedUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
}
@@ -841,7 +850,7 @@ export type UserCreateWithoutRoleInput = {
updatedAt?: Date | string
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
@@ -856,7 +865,7 @@ export type UserUncheckedCreateWithoutRoleInput = {
updatedAt?: Date | string
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileUncheckedCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
@@ -912,7 +921,7 @@ export type UserCreateWithoutUserProclamationsInput = {
role?: Prisma.UserRoleCreateNestedOneWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
}
@@ -927,7 +936,7 @@ export type UserUncheckedCreateWithoutUserProclamationsInput = {
roleId?: number | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileUncheckedCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
}
@@ -958,7 +967,7 @@ export type UserUpdateWithoutUserProclamationsInput = {
role?: Prisma.UserRoleUpdateOneWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
}
@@ -973,7 +982,7 @@ export type UserUncheckedUpdateWithoutUserProclamationsInput = {
roleId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUncheckedUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
}
@@ -988,7 +997,7 @@ export type UserCreateWithoutLicenseKeysInput = {
role?: Prisma.UserRoleCreateNestedOneWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
@@ -1003,7 +1012,7 @@ export type UserUncheckedCreateWithoutLicenseKeysInput = {
roleId?: number | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profile?: Prisma.ProfileUncheckedCreateNestedOneWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
@@ -1034,7 +1043,7 @@ export type UserUpdateWithoutLicenseKeysInput = {
role?: Prisma.UserRoleUpdateOneWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
@@ -1049,7 +1058,7 @@ export type UserUncheckedUpdateWithoutLicenseKeysInput = {
roleId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUncheckedUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
}
@@ -1073,7 +1082,7 @@ export type UserUpdateWithoutRoleInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
@@ -1088,7 +1097,7 @@ export type UserUncheckedUpdateWithoutRoleInput = {
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profile?: Prisma.ProfileUncheckedUpdateOneWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
}
@@ -1111,6 +1120,7 @@ export type UserUncheckedUpdateManyWithoutRoleInput = {
export type UserCountOutputType = {
sessions: number
accounts: number
profiles: number
licenseKeys: number
userProclamations: number
}
@@ -1118,6 +1128,7 @@ export type UserCountOutputType = {
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
sessions?: boolean | UserCountOutputTypeCountSessionsArgs
accounts?: boolean | UserCountOutputTypeCountAccountsArgs
profiles?: boolean | UserCountOutputTypeCountProfilesArgs
licenseKeys?: boolean | UserCountOutputTypeCountLicenseKeysArgs
userProclamations?: boolean | UserCountOutputTypeCountUserProclamationsArgs
}
@@ -1146,6 +1157,13 @@ export type UserCountOutputTypeCountAccountsArgs<ExtArgs extends runtime.Types.E
where?: Prisma.AccountWhereInput
}
/**
* UserCountOutputType without action
*/
export type UserCountOutputTypeCountProfilesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.ProfileWhereInput
}
/**
* UserCountOutputType without action
*/
@@ -1173,7 +1191,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
role?: boolean | Prisma.User$roleArgs<ExtArgs>
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
profile?: boolean | Prisma.User$profileArgs<ExtArgs>
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
@@ -1219,7 +1237,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
role?: boolean | Prisma.User$roleArgs<ExtArgs>
sessions?: boolean | Prisma.User$sessionsArgs<ExtArgs>
accounts?: boolean | Prisma.User$accountsArgs<ExtArgs>
profile?: boolean | Prisma.User$profileArgs<ExtArgs>
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
@@ -1237,7 +1255,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
role: Prisma.$UserRolePayload<ExtArgs> | null
sessions: Prisma.$SessionPayload<ExtArgs>[]
accounts: Prisma.$AccountPayload<ExtArgs>[]
profile: Prisma.$ProfilePayload<ExtArgs> | null
profiles: Prisma.$ProfilePayload<ExtArgs>[]
licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[]
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
}
@@ -1647,7 +1665,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
role<T extends Prisma.User$roleArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$roleArgs<ExtArgs>>): Prisma.Prisma__UserRoleClient<runtime.Types.Result.GetResult<Prisma.$UserRolePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
sessions<T extends Prisma.User$sessionsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$sessionsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SessionPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
accounts<T extends Prisma.User$accountsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$accountsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$AccountPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
profile<T extends Prisma.User$profileArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profileArgs<ExtArgs>>): Prisma.Prisma__ProfileClient<runtime.Types.Result.GetResult<Prisma.$ProfilePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
profiles<T extends Prisma.User$profilesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profilesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProfilePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
licenseKeys<T extends Prisma.User$licenseKeysArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$licenseKeysArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
userProclamations<T extends Prisma.User$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/**
@@ -2155,9 +2173,9 @@ export type User$accountsArgs<ExtArgs extends runtime.Types.Extensions.InternalA
}
/**
* User.profile
* User.profiles
*/
export type User$profileArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
export type User$profilesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Profile
*/
@@ -2171,6 +2189,11 @@ export type User$profileArgs<ExtArgs extends runtime.Types.Extensions.InternalAr
*/
include?: Prisma.ProfileInclude<ExtArgs> | null
where?: Prisma.ProfileWhereInput
orderBy?: Prisma.ProfileOrderByWithRelationInput | Prisma.ProfileOrderByWithRelationInput[]
cursor?: Prisma.ProfileWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.ProfileScalarFieldEnum | Prisma.ProfileScalarFieldEnum[]
}
/**
+22 -47
View File
@@ -5,9 +5,9 @@ import {
canonicalIdForFrequency,
serializeCategory,
serializeColorPalette,
serializeGroup,
serializeLicense,
serializeProclamation,
serializeSubscriptionLevel
serializeProclamation
} from './v2-serializers';
describe('canonicalIdForFrequency', () => {
@@ -61,7 +61,7 @@ describe('serializeProclamation', () => {
name: 'Peace & Relaxation',
content: '<p>The text content, HTML formatted</p>',
categoryId: 5,
subscriptionLevelId: 1
groupId: 1
};
it('derives solfeggioFrequencyId from the Hz value, not the DB id', () => {
@@ -84,13 +84,13 @@ describe('serializeProclamation', () => {
expect(record).toBeNull();
});
it('passes through a null subscriptionLevelId (kiosk treats as level 1)', () => {
it('passes through categoryId and groupId unchanged', () => {
const record = serializeProclamation({
...base,
subscriptionLevelId: null,
solfeggioFrequency: solarPlexus528
});
expect(record?.subscriptionLevelId).toBeNull();
expect(record?.categoryId).toBe(5);
expect(record?.groupId).toBe(1);
});
it('embeds a 7-color palette when provided', () => {
@@ -116,64 +116,39 @@ describe('serializeProclamation', () => {
});
describe('serializeCategory', () => {
it('keeps parentId null for root categories', () => {
expect(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0, parentId: null })).toEqual({
it('maps rows to a flat {id, name, sortOrder} with no parentId', () => {
expect(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0 })).toEqual({
id: 4,
name: 'Spiritual',
sortOrder: 0,
parentId: null
sortOrder: 0
});
expect(
Object.keys(serializeCategory({ id: 4, name: 'Spiritual', sortOrder: 0 }))
).not.toContain('parentId');
});
});
it('keeps parentId int for children', () => {
expect(serializeCategory({ id: 5, name: 'General', sortOrder: 0, parentId: 4 })).toEqual({
id: 5,
describe('serializeGroup', () => {
it('maps rows to {id, name, sortOrder}', () => {
expect(serializeGroup({ id: 1, name: 'General', sortOrder: 0 })).toEqual({
id: 1,
name: 'General',
sortOrder: 0,
parentId: 4
sortOrder: 0
});
});
});
describe('serializeLicense', () => {
it('returns subscription_level as an object when the level row exists', () => {
it('returns only key and email_address (levels removed)', () => {
const result = serializeLicense({
key: 'XXXX-XXXX-XXXX-XXXX',
emailAddress: 'user@example.com',
levelName: 'Advanced',
level: { id: 2, name: 'Advanced', description: 'General + Specific' }
emailAddress: 'user@example.com'
});
expect(result).toEqual({
key: 'XXXX-XXXX-XXXX-XXXX',
email_address: 'user@example.com',
subscription_level: { id: 2, name: 'Advanced', description: 'General + Specific' }
});
});
it('falls back to a structurally consistent object when no level row matches', () => {
const result = serializeLicense({
key: 'XXXX-XXXX-XXXX-XXXX',
emailAddress: 'user@example.com',
levelName: 'SomeCustomLevel',
level: null
});
expect(result).toEqual({
key: 'XXXX-XXXX-XXXX-XXXX',
email_address: 'user@example.com',
subscription_level: { id: null, name: 'SomeCustomLevel', description: null }
});
});
});
describe('serializeSubscriptionLevel', () => {
it('maps rows to {id, name, description}', () => {
expect(
serializeSubscriptionLevel({ id: 1, name: 'Basic', description: 'General only' })
).toEqual({
id: 1,
name: 'Basic',
description: 'General only'
email_address: 'user@example.com'
});
expect(Object.keys(result)).not.toContain('subscription_level');
});
});
+14 -35
View File
@@ -55,8 +55,8 @@ export type V2Proclamation = {
name: string;
content: string;
categoryId: number;
groupId: number;
solfeggioFrequencyId: number;
subscriptionLevelId: number | null;
colorPalette?: string[];
};
@@ -65,7 +65,7 @@ export type ProclamationWithFrequency = {
name: string;
content: string;
categoryId: number;
subscriptionLevelId: number | null;
groupId: number;
solfeggioFrequency: { value: number };
};
@@ -83,8 +83,8 @@ export function serializeProclamation(
name: p.name,
content: p.content,
categoryId: p.categoryId,
solfeggioFrequencyId,
subscriptionLevelId: p.subscriptionLevelId
groupId: p.groupId,
solfeggioFrequencyId
};
if (palette && palette.length > 0) {
@@ -98,53 +98,32 @@ export type V2Category = {
id: number;
name: string;
sortOrder: number;
parentId: number | null;
};
export function serializeCategory(c: {
export function serializeCategory(c: { id: number; name: string; sortOrder: number }): V2Category {
return { id: c.id, name: c.name, sortOrder: c.sortOrder };
}
export type V2Group = {
id: number;
name: string;
sortOrder: number;
parentId: number | null;
}): V2Category {
return { id: c.id, name: c.name, sortOrder: c.sortOrder, parentId: c.parentId };
}
export type V2SubscriptionLevel = {
id: number;
name: string;
description: string | null;
};
export function serializeSubscriptionLevel(level: {
id: number;
name: string;
description: string | null;
}): V2SubscriptionLevel {
return { id: level.id, name: level.name, description: level.description };
export function serializeGroup(g: { id: number; name: string; sortOrder: number }): V2Group {
return { id: g.id, name: g.name, sortOrder: g.sortOrder };
}
export type V2License = {
key: string;
email_address: string;
subscription_level: { id: number | null; name: string; description: string | null };
};
/** Build the POST /license response. `subscription_level` is always an object;
* when no SubscriptionLevel row matches the license's level name, fall back to
* a structurally consistent object with a null id/description. */
export function serializeLicense(opts: {
key: string;
emailAddress: string;
levelName: string;
level: { id: number; name: string; description: string | null } | null;
}): V2License {
/** Build the POST /license response. */
export function serializeLicense(opts: { key: string; emailAddress: string }): V2License {
return {
key: opts.key,
email_address: opts.emailAddress,
subscription_level: opts.level
? { id: opts.level.id, name: opts.level.name, description: opts.level.description }
: { id: null, name: opts.levelName, description: null }
email_address: opts.emailAddress
};
}
+1 -1
View File
@@ -17,7 +17,7 @@ function signedRequest(opts: {
const path = '/api/v2/automations/create/account';
const timestamp = String(Math.floor((opts.now ?? new Date()).getTime() / 1000));
const nonce = 'a48c0f2e-5f34-4b0b-9a12-3c7d6b8e1f00';
const body = '{"email":"user@example.com","subscription_level":"Basic"}';
const body = '{"email":"user@example.com"}';
let ts = timestamp;
let nonceValue = nonce;
+23
View File
@@ -0,0 +1,23 @@
export function superFormErrorsToFieldErrors(superErrors: Record<string, unknown>): {
error: string;
fieldErrors: Record<string, string>;
} {
const fieldErrors: Record<string, string> = {};
let formError = '';
for (const [key, value] of Object.entries(superErrors)) {
if (key === '_errors' && Array.isArray(value)) {
formError = (value[0] as string) ?? '';
} else if (
value &&
typeof value === 'object' &&
'_errors' in value &&
Array.isArray((value as Record<string, unknown>)._errors)
) {
const errMsg = ((value as Record<string, unknown>)._errors as string[])[0];
fieldErrors[key] = errMsg;
} else if (Array.isArray(value) && typeof value[0] === 'string') {
fieldErrors[key] = value[0];
}
}
return { error: formError, fieldErrors };
}
-391
View File
@@ -1,391 +0,0 @@
# svelte-admin
A config-driven admin panel framework for SvelteKit + Prisma. Define your models once in a
registry and get full CRUD UI with per-model static pages, role-based access control, searchable
tables, FK dropdowns, TipTap editor for rich text, and related-record panels.
## Architecture
```
src/lib/svelte-admin/ # Framework core
├── types.ts # AdminModelConfig and related types
├── registry.ts # Model configurations (the single source of truth)
├── roles.ts # Role hierarchy and access helpers
├── prisma-delegate.ts # Dynamic Prisma delegate lookup
├── introspect.ts # Field label generation and dot-notation resolution
├── form-data.ts # FormData -> typed object parser
├── crud.ts # CRUD action factory (load, upsert, delete)
└── index.ts # Barrel exports
src/routes/(protected)/admin/ # Per-model admin pages (no dynamic [model] routing)
├── +layout.server.ts # Role-based access guard
├── +layout.svelte # Layout shell
├── +page.server.ts # Admin home (model grid)
├── +page.svelte # Admin home UI
├── users/ # Each model gets its own page directory
├── profiles/
├── addresses/
├── user-roles/
├── proclamations/
├── solfeggio-frequencies/
├── proclamation-categories/
├── subscription-levels/
└── license-keys/ # listOnly — no create/edit modal
src/lib/components/admin/ # Reusable admin UI components
├── AdminTable.svelte # Sortable, searchable data table
├── AdminForm.svelte # Dynamic form with type-aware inputs
├── AdminModal.svelte # Modal overlay for CRUD forms
├── TipTap.svelte # Rich-text editor (in components/tiptap/)
```
## Adding a New Model
1. **Define the Prisma model** in `prisma/schema.prisma` and create a migration:
```bash
npm run migrate -- --name add_my_model
```
2. **Add a config object** to the `models` array in `registry.ts`:
```ts
{
slug: 'widgets', // URL segment: /admin/widgets
modelName: 'Widget', // Prisma model name (PascalCase, singular)
displayName: 'Widgets', // Plural display name
displayNameSingular: 'Widget', // Singular display name
idType: 'String', // 'String' or 'Int' — matches Prisma @id type
include: { category: true }, // Optional: Prisma include for relations
listFields: ['name', 'category.name', 'price'],
searchFields: ['name'],
formFields: ['name', 'categoryId', 'price'],
readonlyFields: ['id', 'createdAt'],
fieldTypes: {
name: 'String',
categoryId: 'Int',
price: 'Float',
createdAt: 'DateTime'
},
relationOptions: {
categoryId: {
model: 'Category',
labelField: 'name',
valueField: 'id'
}
}
}
```
3. **Create the page directory** `src/routes/(protected)/admin/widgets/` with:
- `+page.server.ts` — load records + form actions using `crud.ts` helpers
- `+page.svelte` — render AdminTable + AdminModal with AdminForm
4. **Done.** Visit `/admin/widgets`. The page handles list, create, edit via modal.
## Configuration Reference
### AdminModelConfig
| Field | Type | Required | Description |
| --------------------- | ---------------------------------------------- | -------- | ------------------------------------------------------------------------------------------ |
| `slug` | `string` | Yes | URL segment (kebab-case). Used in `/admin/{slug}`. |
| `modelName` | `string` | Yes | Prisma model name (PascalCase, **singular**). Maps to `prisma[lcfirst(modelName)]`. |
| `displayName` | `string` | Yes | Plural display name for headings and navigation. |
| `displayNameSingular` | `string` | Yes | Singular name for edit page headings. |
| `idType` | `'String' \| 'Int'` | Yes | Primary key type. Controls how the `id` URL param is parsed. |
| `include` | `Record<string, boolean \| { include: ... }>` | No | Prisma `include` clause for eager-loading relations. |
| `listFields` | `string[]` | Yes | Table columns. Supports dot-notation for relations (e.g., `'role.name'`). |
| `searchFields` | `string[]` | Yes | Fields searched by the client-side search filter. |
| `formFields` | `string[]` | Yes | Fields rendered in create/edit forms, in display order. |
| `readonlyFields` | `string[]` | No | Fields shown as read-only on the edit page. |
| `listOnly` | `boolean` | No | If `true`, suppresses edit links in the table and no modal. |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Yes | Maps field names to scalar types. Controls input rendering and FormData parsing. |
| `relationOptions` | `Record<string, RelationOptionConfig>` | No | FK fields that render as `<select>` dropdowns. |
| `relatedRecords` | `RelatedRecordConfig[]` | No | Has-one/has-many panels shown on edit pages (display only via related records table). |
| `accessFilter` | `(viewerRoleId: number) => where \| undefined` | No | Returns a Prisma `where` clause to filter records by viewer role. `undefined` = no filter. |
### PrismaScalarType
`'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Tiptap'`
Controls both form input rendering and FormData parsing:
| Type | HTML Input | Parse Logic |
| ---------- | -------------------------------------- | ------------------------------- |
| `String` | `<input type="text">` | `raw.toString()` |
| `Int` | `<input type="number" step="1">` | `parseInt()`, empty -> `null` |
| `Float` | `<input type="number" step="0.01">` | `parseFloat()`, empty -> `null` |
| `Boolean` | `<input type="checkbox">` | `'on'` or `'true'` -> `true` |
| `DateTime` | `<input type="datetime-local">` | `new Date()`, empty -> `null` |
| `Tiptap` | TipTap rich-text editor (hidden input) | `raw.toString()` |
### RelationOptionConfig
```ts
{
model: 'UserRole', // Prisma model to query for options
labelField: 'name', // Field displayed in the dropdown
valueField: 'id' // Field value submitted with the form
}
```
### RelatedRecordConfig
```ts
{
relationKey: 'profile', // Key on parent record (must match a key in `include`)
label: 'Profile', // Section heading on edit page
type: 'has-one', // 'has-one' or 'has-many'
displayFields: ['firstName', 'lastName'], // Fields shown in the panel/table
editSlug: 'profiles' // Optional: admin slug to link for editing
}
```
## Field Types & Form Rendering
The `fieldTypes` mapping is the central mechanism connecting Prisma scalar types to HTML inputs
and form data parsing. Every field in `formFields` must have a corresponding entry in `fieldTypes`.
When a field also has a `relationOptions` entry, the framework renders a `<select>` dropdown
instead of the default input for that type. The options are fetched server-side by `crud.ts`
and passed to the client as `relationOptionsData`.
For `Tiptap` fields, the TipTap rich-text editor is rendered. The editor content is stored in a
hidden input and submitted with the form.
## Relations
### Foreign Key Dropdowns (relationOptions)
When a form field is a foreign key, add a `relationOptions` entry to render a `<select>` dropdown:
```ts
{
formFields: ['name', 'categoryId'],
fieldTypes: { name: 'String', categoryId: 'Int' },
relationOptions: {
categoryId: {
model: 'ProclamationCategory',
labelField: 'name',
valueField: 'id'
}
}
}
```
The `crud.ts` `loadRecords` function automatically queries the related model and passes options
to the page as `relationOptionsData`.
### Eager Loading (include)
Use `include` to load related data for display in list tables or edit pages:
```ts
{
include: { category: { include: { parent: true } } },
listFields: ['name', 'category.name'] // dot-notation resolves nested values
}
```
## Related Records
Display has-one or has-many related records as panels on the edit page using `relatedRecords`:
```ts
relatedRecords: [
{
relationKey: 'profile',
label: 'Profile',
type: 'has-one',
displayFields: ['firstName', 'lastName'],
editSlug: 'profiles' // links to /admin/profiles/{id}
},
{
relationKey: 'licenseKeys',
label: 'License Keys',
type: 'has-many',
displayFields: ['key', 'active', 'level']
}
];
```
The `relationKey` must match a key in `include` so the data is actually loaded.
## Access Control
### Role Hierarchy
Defined in `roles.ts`. Lower numbers = higher privilege:
| Constant | Value | Access Level |
| ------------- | ----- | -------------------------------------- |
| `ROLES.SUPER` | 1 | Full access, sees all users |
| `ROLES.ADMIN` | 2 | Admin access, sees staff and below |
| `ROLES.STAFF` | 3 | Staff access (minimum for admin panel) |
| `ROLES.USER` | 4 | Regular user (no admin access) |
### Layout Guard
`+layout.server.ts` calls `canAccessAdmin(user.roleId)` which returns `true` when
`roleId <= ROLES.STAFF`. Users with `ROLES.USER` (4) or no role get a 403.
### Per-Model Access Filtering
Use `accessFilter` to restrict which records a viewer can see:
```ts
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined;
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } };
}
return { roleId: { gt: ROLES.STAFF } };
};
```
## How Static Pages Work
1. User visits `/admin/proclamations`
2. SvelteKit matches the static route `/admin/proclamations/+page.server.ts`
3. `+page.server.ts` calls `getModelConfig('proclamations')` to look up the config
4. `loadRecords(config)` calls `prisma.proclamation.findMany()` with the config's `include` and `accessFilter`
5. For each FK field with a `relationOptions`, it also queries the related model for dropdown options
6. Data + config are passed to `+page.svelte`
7. The page renders `AdminTable` with records and `AdminModal` + `AdminForm` for create/edit
## Component API
### AdminTable
| Prop | Type | Description |
| -------------- | ---------------------------------- | ------------------------------------ |
| `records` | `Record<string, unknown>[]` | Data rows |
| `listFields` | `string[]` | Column keys (dot-notation supported) |
| `searchFields` | `string[]` | Fields for client-side search |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Type mapping for cell formatting |
| `singular` | `string` | Singular label for UI text |
| `onRowClick` | `(record) => void?` | Click handler for edit modal |
| `onNew` | `() => void?` | Click handler for create modal |
Features: client-side search, click-to-sort columns, auto-formatted cells (dates, booleans,
decimals), empty state.
### AdminForm
| Prop | Type | Description |
| --------------------- | --------------------------------------- | -------------------------------- |
| `fields` | `string[]` | Field names to render |
| `fieldTypes` | `Record<string, PrismaScalarType>` | Type mapping for inputs |
| `relationOptions` | `Record<string, RelationOptionConfig>?` | FK dropdown configs |
| `relationOptionsData` | `Record<string, { label; value }[]>?` | Loaded dropdown options |
| `record` | `Record<string, unknown>?` | Pre-fill values (edit mode) |
| `readonlyFields` | `string[]?` | Read-only field names |
| `action` | `string` | Form action (e.g., `'?/upsert'`) |
| `submitLabel` | `string?` | Button text |
### AdminModal
| Prop | Type | Description |
| ---------- | ------------ | ------------------ |
| `open` | `boolean` | Show/hide modal |
| `title` | `string` | Modal heading |
| `onClose` | `() => void` | Close handler |
| `children` | `Snippet` | Modal body content |
### TipTap
| Prop | Type | Description |
| ---------------- | -------- | --------------------------------- |
| `initialContent` | `string` | Initial HTML content |
| `name` | `string` | Hidden input name for form submit |
## Utility Functions
| Function | Module | Description |
| ------------------------------------------ | -------------------- | ------------------------------------------------------------------------------ |
| `fieldNameToLabel(name)` | `introspect.ts` | `'solfeggioFrequencyId'` -> `'Solfeggio Frequency'` (strips `Id`, adds spaces) |
| `resolveFieldValue(record, path)` | `introspect.ts` | Resolves dot-notation paths like `'category.name'` on nested objects |
| `extractFormData(formData, fields, types)` | `form-data.ts` | Parses `FormData` with type-aware conversion per `PrismaScalarType` |
| `getDelegate(config)` | `prisma-delegate.ts` | Returns `prisma[lcfirst(modelName)]` delegate |
| `parseId(config, raw)` | `prisma-delegate.ts` | Parses string to `number` when `idType` is `'Int'` |
| `getModelConfig(slug)` | `registry.ts` | Looks up config by URL slug |
| `getAllModelConfigs()` | `registry.ts` | Returns all registered model configs |
| `hasRole(roleId, required)` | `roles.ts` | `true` if `roleId <= required` |
| `canAccessAdmin(roleId)` | `roles.ts` | `true` if `roleId <= ROLES.STAFF` |
| `loadRecords(locals, config)` | `crud.ts` | Fetches records + FK dropdown options for a model |
| `upsertRecord(formData, config, locals)` | `crud.ts` | Creates or updates a record based on presence of `id` in form data |
| `deleteRecord(formData, config, locals)` | `crud.ts` | Deletes a record by ID |
## Models in Admin
| Registry Slug | Prisma Model | Notes |
| ------------------------- | -------------------- | ----------------------------------- |
| `users` | User | Role assignment, view-only fields |
| `profiles` | Profile | Linked to user FK |
| `addresses` | Address | Linked to profile FK |
| `user-roles` | UserRole | |
| `proclamations` | Proclamation | TipTap rich-text editor for content |
| `solfeggio-frequencies` | SolfeggioFrequency | |
| `proclamation-categories` | ProclamationCategory | Parent category hierarchy |
| `license-keys` | LicenseKey | listOnly — read-only table, no CRUD |
| `subscription-levels` | SubscriptionLevel | |
### Excluded from Admin
| Model | Reason |
| ----------------- | --------------------------------------------- |
| Session | Better Auth internal — not for manual editing |
| Account | Better Auth internal |
| Verification | Better Auth internal |
| UserProclamations | Composite key — managed via other models |
## Complete Example
A hypothetical `Product` model showing all configuration features:
```ts
{
slug: 'products',
modelName: 'Product',
displayName: 'Products',
displayNameSingular: 'Product',
idType: 'Int',
include: { category: true, variants: true, createdBy: true },
listFields: ['name', 'category.name', 'price', 'active', 'createdAt'],
searchFields: ['name', 'sku'],
formFields: ['name', 'sku', 'price', 'active', 'categoryId'],
readonlyFields: ['id', 'createdAt', 'updatedAt'],
fieldTypes: {
name: 'String',
sku: 'String',
price: 'Float',
active: 'Boolean',
categoryId: 'Int',
createdAt: 'DateTime',
updatedAt: 'DateTime'
},
relationOptions: {
categoryId: { model: 'Category', labelField: 'name', valueField: 'id' }
},
relatedRecords: [
{
relationKey: 'variants',
label: 'Variants',
type: 'has-many',
displayFields: ['size', 'color', 'stock'],
editSlug: 'variants'
},
{
relationKey: 'createdBy',
label: 'Created By',
type: 'has-one',
displayFields: ['name', 'email']
}
],
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= 2) return undefined;
return { active: true };
}
}
```
-227
View File
@@ -1,227 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLocals, mockUser } from '$lib/test-utils';
import { getModelConfig } from './registry';
// Ensure superValidate can be imported from server path in Node.js/SSR context
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
vi.mock('$lib/db', () => {
const mkdel = () => ({
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findUnique: vi.fn(),
findFirst: vi.fn()
});
return {
prisma: {
userRole: mkdel(),
proclamation: mkdel(),
proclamationCategory: mkdel(),
solfeggioFrequency: mkdel(),
subscriptionLevel: mkdel()
}
};
});
import { prisma } from '$lib/db';
import { loadRecords, upsertRecord, deleteRecord } from './crud';
const userRoleFindMany = prisma.userRole.findMany as ReturnType<typeof vi.fn>;
const create = prisma.userRole.create as ReturnType<typeof vi.fn>;
const update = prisma.userRole.update as ReturnType<typeof vi.fn>;
const deletes = prisma.userRole.delete as ReturnType<typeof vi.fn>;
const findUnique = prisma.userRole.findUnique as ReturnType<typeof vi.fn>;
const findFirst = prisma.userRole.findFirst as ReturnType<typeof vi.fn>;
const config = getModelConfig('user-roles')!;
describe('crud loadRecords', () => {
beforeEach(() => {
userRoleFindMany.mockReset();
});
it('returns records', async () => {
userRoleFindMany.mockResolvedValue([{ id: 1, name: 'Admin' }]);
const locals = createLocals(mockUser() as any);
const result = await loadRecords(locals, config);
expect(result.records).toEqual([{ id: 1, name: 'Admin' }]);
});
it('returns empty records array', async () => {
userRoleFindMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = await loadRecords(locals, config);
expect(result.records).toEqual([]);
});
});
describe('crud upsertRecord', () => {
beforeEach(() => {
create.mockReset();
update.mockReset();
findUnique.mockReset();
findFirst.mockReset();
});
it('creates a new record when no id', async () => {
create.mockResolvedValue({ id: 2, name: 'Editor' });
const fd = new FormData();
fd.set('name', 'Editor');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(create).toHaveBeenCalledWith({ data: { name: 'Editor' } });
expect(result).toEqual({ success: true });
});
it('updates an existing record when id is present', async () => {
findUnique.mockResolvedValue({ id: 1, name: 'Old Name' });
const fd = new FormData();
fd.set('id', '1');
fd.set('name', 'New Name');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(update).toHaveBeenCalledWith({ where: { id: 1 }, data: { name: 'New Name' } });
expect(result).toEqual({ success: true });
});
it('returns error when updating non-existent record', async () => {
findUnique.mockResolvedValue(null);
const fd = new FormData();
fd.set('id', '999');
fd.set('name', 'Ghost');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({ success: false, error: 'Record not found' });
});
});
describe('crud deleteRecord', () => {
beforeEach(() => {
deletes.mockReset();
});
it('deletes a record by id', async () => {
deletes.mockResolvedValue({ id: 1, name: 'Admin' });
const fd = new FormData();
fd.set('id', '1');
const locals = createLocals(mockUser() as any);
const result = await deleteRecord(fd, config, locals);
expect(deletes).toHaveBeenCalledWith({ where: { id: 1 } });
expect(result).toEqual({ success: true });
});
it('returns error when id is missing', async () => {
const fd = new FormData();
const locals = createLocals(mockUser() as any);
const result = await deleteRecord(fd, config, locals);
expect(result).toEqual({ success: false, error: 'Missing id' });
});
});
describe('P2002 unique constraint handling', () => {
beforeEach(() => {
create.mockReset();
});
it('returns error with field names when meta.target is populated', async () => {
const p2002 = new Error('Unique constraint failed');
(p2002 as any).code = 'P2002';
(p2002 as any).meta = { target: ['name'], modelName: 'UserRole' };
create.mockRejectedValue(p2002);
const fd = new FormData();
fd.set('name', 'Admin');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({
success: false,
error: 'A record with this name already exists.',
fieldErrors: { name: 'Already taken' }
});
});
it('returns generic message when meta.target is empty', async () => {
const p2002 = new Error('Unique constraint failed');
(p2002 as any).code = 'P2002';
(p2002 as any).meta = { target: [], modelName: 'UserRole' };
create.mockRejectedValue(p2002);
const fd = new FormData();
fd.set('name', 'Admin');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({
success: false,
error: 'A database constraint violation occurred. The record could not be created.'
});
});
it('returns generic message when meta.target is missing', async () => {
const p2002 = new Error('Unique constraint failed');
(p2002 as any).code = 'P2002';
(p2002 as any).meta = { modelName: 'UserRole' };
create.mockRejectedValue(p2002);
const fd = new FormData();
fd.set('name', 'Admin');
const locals = createLocals(mockUser() as any);
const result = await upsertRecord(fd, config, locals);
expect(result).toEqual({
success: false,
error: 'A database constraint violation occurred. The record could not be created.'
});
});
});
describe('sveltekit-superforms import compatibility', () => {
it('imports superValidate from server path without pulling in SuperDebug.svelte', async () => {
expect(superValidate).toBeDefined();
});
it('calls superValidate with a Request and validates against a Zod schema', async () => {
const { z } = await import('zod');
const schema = z.object({ name: z.string().min(1) });
const body = new URLSearchParams({ name: 'test' });
const request = new Request('http://localhost', {
method: 'POST',
body,
headers: { 'content-type': 'application/x-www-form-urlencoded' }
});
const form = await superValidate(request, zod4(schema));
expect(form.valid).toBe(true);
expect(form.data).toEqual({ name: 'test' });
});
});
describe('prisma client version consistency', () => {
it('generated client references existing query compiler files', async () => {
const fs = await import('fs');
const path = await import('path');
const runtimeDir = path.resolve('node_modules/@prisma/client/runtime');
const generatedFile = path.resolve('src/lib/generated/prisma/internal/class.ts');
const content = fs.readFileSync(generatedFile, 'utf-8');
const compilerMatch = content.match(/import\(["']@prisma\/client\/runtime\/([^"']+)["']\)/);
expect(compilerMatch).not.toBeNull();
const compilerPath = path.join(runtimeDir, compilerMatch![1]);
expect(fs.existsSync(compilerPath)).toBe(true);
});
});
-190
View File
@@ -1,190 +0,0 @@
import { getDelegate, parseId } from './prisma-delegate';
import { extractFormData } from './form-data';
import type { AdminModelConfig } from './types';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import * as Schemas from './schemas';
import type { z } from 'zod';
const modelSchemas: Record<string, z.ZodObject<any>> = {
User: Schemas.User,
Profile: Schemas.Profile,
UserRole: Schemas.UserRole,
Proclamation: Schemas.Proclamation,
ProclamationCategory: Schemas.ProclamationCategory,
SolfeggioFrequency: Schemas.SolfeggioFrequency,
SubscriptionLevel: Schemas.SubscriptionLevel,
LicenseKey: Schemas.LicenseKey
};
export function superFormErrorsToFieldErrors(superErrors: Record<string, unknown>): {
error: string;
fieldErrors: Record<string, string>;
} {
const fieldErrors: Record<string, string> = {};
let formError = '';
for (const [key, value] of Object.entries(superErrors)) {
if (key === '_errors' && Array.isArray(value)) {
formError = (value[0] as string) ?? '';
} else if (
value &&
typeof value === 'object' &&
'_errors' in value &&
Array.isArray((value as Record<string, unknown>)._errors)
) {
const errMsg = ((value as Record<string, unknown>)._errors as string[])[0];
fieldErrors[key] = errMsg;
}
}
return { error: formError, fieldErrors };
}
export async function loadRecords(locals: App.Locals, config: AdminModelConfig) {
const delegate = getDelegate(config);
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const where = config.accessFilter?.(viewerRoleId);
const records = await delegate.findMany({
where,
include: config.include,
orderBy: { id: 'asc' }
});
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) {
const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
const selectFields: Record<string, boolean> = {
[relConfig.valueField]: true,
[relConfig.labelField]: true
};
const options = await relDelegate.findMany({
select: selectFields,
orderBy: { [relConfig.labelField]: 'asc' }
});
const mapped = (options as Record<string, unknown>[]).map((opt: Record<string, unknown>) => ({
label: String(opt[relConfig.labelField]),
value: opt[relConfig.valueField] as string | number
}));
relationOptionsData[field] = mapped;
}
return {
records: records as Record<string, unknown>[],
relationOptionsData
};
}
export async function upsertRecord(
requestOrFormData: Request | FormData,
config: AdminModelConfig,
locals: App.Locals
) {
const formData =
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
const schema = modelSchemas[config.modelName];
const form = schema ? await superValidate(formData, zod4(schema)) : null;
if (form && !form.valid) {
const { error, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { ...form, success: false, error, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const data =
form?.data ??
(extractFormData(formData, config.formFields, config.fieldTypes) as Record<string, unknown>);
// Strip virtual/UI-only fields from Prisma payload
for (const f of config.dataOnlyFields ?? []) {
delete data[f];
}
const delegate = getDelegate(config);
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const where = config.accessFilter?.(viewerRoleId);
if (rawId) {
const id = parseId(config, rawId);
const existing = await delegate.findUnique({
where: { id },
...((config.include ? { include: config.include } : {}) as object)
});
if (!existing) {
return { success: false, error: 'Record not found' };
}
if (where) {
const match = await delegate.findFirst({
where: { id, ...where }
});
if (!match) {
return { success: false, error: 'Access denied' };
}
}
await delegate.update({
where: { id },
data
});
return { success: true };
} else {
try {
await delegate.create({ data });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
const meta = (e as { meta?: { target?: string[]; modelName?: string } }).meta;
const fields = meta?.target ?? [];
console.error(
`[crud] P2002 on ${meta?.modelName ?? 'unknown'} create. data=`,
data,
`meta.target=`,
meta?.target
);
if (fields.length === 0) {
return {
success: false,
error: 'A database constraint violation occurred. The record could not be created.'
};
}
return {
success: false,
error: `A record with this ${fields.join(', ')} already exists.`,
fieldErrors: Object.fromEntries(fields.map((f) => [f, 'Already taken']))
};
}
throw e;
}
return { success: true };
}
}
export async function deleteRecord(
requestOrFormData: Request | FormData,
config: AdminModelConfig,
locals: App.Locals
) {
const delegate = getDelegate(config);
const formData =
requestOrFormData instanceof FormData ? requestOrFormData : await requestOrFormData.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
const id = parseId(config, rawId);
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const where = config.accessFilter?.(viewerRoleId);
if (where) {
const match = await delegate.findFirst({ where: { id, ...where } });
if (!match) return { success: false, error: 'Access denied' };
}
await delegate.delete({ where: { id } });
return { success: true };
}
-46
View File
@@ -1,46 +0,0 @@
import type { PrismaScalarType } from './types';
export function extractFormData(
formData: FormData,
formFields: string[],
fieldTypes: Record<string, PrismaScalarType>
): Record<string, unknown> {
const data: Record<string, unknown> = {};
for (const field of formFields) {
const type = fieldTypes[field] ?? 'String';
const raw = formData.get(field);
switch (type) {
case 'Int': {
const val = raw?.toString() ?? '';
data[field] = val === '' ? null : parseInt(val, 10);
break;
}
case 'Float': {
const val = raw?.toString() ?? '';
data[field] = val === '' ? null : parseFloat(val);
break;
}
case 'Boolean': {
data[field] = raw === 'on' || raw === 'true';
break;
}
case 'DateTime': {
const val = raw?.toString() ?? '';
data[field] = val === '' ? null : new Date(val);
break;
}
case 'Tiptap': {
data[field] = raw?.toString() ?? '';
break;
}
default: {
data[field] = raw?.toString() ?? '';
break;
}
}
}
return data;
}
-7
View File
@@ -1,7 +0,0 @@
export type { AdminModelConfig, RelationOptionConfig, AdminField, PrismaScalarType } from './types';
export { ROLES, hasRole, canAccessAdmin } from './roles';
export { fieldNameToLabel, resolveFieldValue } from './introspect';
export { getDelegate, parseId } from './prisma-delegate';
export { extractFormData } from './form-data';
export { getModelConfig, getAllModelConfigs, getSerializableModelConfigs } from './registry';
export type { SerializableModelConfig } from './registry';
-15
View File
@@ -1,15 +0,0 @@
export function fieldNameToLabel(fieldName: string): string {
const withoutId = fieldName.replace(/Id$/, '');
const spaced = withoutId.replace(/([a-z])([A-Z])/g, '$1 $2');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
export function resolveFieldValue(record: Record<string, unknown>, path: string): unknown {
const parts = path.split('.');
let current: unknown = record;
for (const part of parts) {
if (current == null || typeof current !== 'object') return undefined;
current = (current as Record<string, unknown>)[part];
}
return current;
}
-26
View File
@@ -1,26 +0,0 @@
import { prisma } from '$lib/db';
import type { AdminModelConfig } from './types';
function lcfirst(str: string): string {
return str.charAt(0).toLowerCase() + str.slice(1);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function getDelegate(config: AdminModelConfig): any {
const key = lcfirst(config.modelName);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const delegate = (prisma as any)[key];
if (!delegate) {
throw new Error(`Prisma delegate not found for model: ${config.modelName} (key: ${key})`);
}
return delegate;
}
export function parseId(config: AdminModelConfig, raw: string): string | number {
if (config.idType === 'Int') {
const parsed = parseInt(raw, 10);
if (isNaN(parsed)) throw new Error(`Invalid integer ID: ${raw}`);
return parsed;
}
return raw;
}
-290
View File
@@ -1,290 +0,0 @@
import type { AdminModelConfig } from './types';
import { ROLES } from './roles';
const models: AdminModelConfig[] = [
{
slug: 'users',
modelName: 'User',
displayName: 'Users',
displayNameSingular: 'User',
listOnly: true,
idType: 'String',
include: { role: true, profile: true, licenseKeys: true },
listFields: ['name', 'email', 'emailVerified', 'role.name', 'createdAt'],
searchFields: ['name', 'email'],
formFields: ['roleId'],
readonlyFields: ['id', 'name', 'email', 'emailVerified', 'createdAt', 'updatedAt'],
fieldTypes: {
name: 'String',
email: 'String',
emailVerified: 'Boolean',
roleId: 'Int',
createdAt: 'DateTime',
updatedAt: 'DateTime'
},
relationOptions: {
roleId: { model: 'UserRole', labelField: 'name', valueField: 'id' }
},
relatedRecords: [
{
relationKey: 'profile',
label: 'Profile',
type: 'has-one',
displayFields: ['firstName', 'lastName', 'phoneNumber'],
editSlug: 'profiles'
},
{
relationKey: 'licenseKeys',
label: 'License Keys',
type: 'has-many',
displayFields: ['key', 'active', 'level']
}
],
accessFilter: (viewerRoleId: number) => {
if (viewerRoleId <= ROLES.SUPER) return undefined;
if (viewerRoleId <= ROLES.ADMIN) {
return { roleId: { gte: ROLES.STAFF } };
}
return { roleId: { gt: ROLES.STAFF } };
}
},
{
slug: 'profiles',
modelName: 'Profile',
displayName: 'Profiles',
displayNameSingular: 'Profile',
idType: 'String',
include: { user: true, addresses: true },
listFields: ['firstName', 'lastName', 'phoneNumber', 'user.name'],
searchFields: ['firstName', 'lastName', 'phoneNumber'],
formFields: ['userId', 'firstName', 'lastName', 'phoneNumber'],
readonlyFields: ['id'],
fieldTypes: {
userId: 'String',
firstName: 'String',
lastName: 'String',
phoneNumber: 'String'
},
relationOptions: {
userId: { model: 'User', labelField: 'name', valueField: 'id' }
},
relatedRecords: [
{
relationKey: 'addresses',
label: 'Addresses',
type: 'has-many',
displayFields: ['type', 'street', 'city', 'state', 'postalCode', 'country', 'isDefault']
}
]
},
{
slug: 'user-roles',
modelName: 'UserRole',
displayName: 'User Roles',
displayNameSingular: 'User Role',
idType: 'Int',
listFields: ['id', 'name'],
searchFields: ['name'],
formFields: ['name'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String'
}
},
{
slug: 'proclamations',
modelName: 'Proclamation',
displayName: 'Proclamations',
displayNameSingular: 'Proclamation',
idType: 'String',
include: {
solfeggioFrequency: true,
category: { include: { parent: true } },
subscriptionLevel: true
},
listFields: ['name', 'category.name', 'solfeggioFrequency.name', 'subscriptionLevel.name'],
searchFields: ['name', 'content'],
formFields: [
'name',
'solfeggioFrequencyId',
'parentCategoryId',
'categoryId',
'subscriptionLevelId',
'content'
],
readonlyFields: ['id'],
dataOnlyFields: ['parentCategoryId'],
cascadeFields: { categoryId: { parentField: 'parentCategoryId' } },
fieldTypes: {
name: 'String',
content: 'Tiptap',
solfeggioFrequencyId: 'Int',
parentCategoryId: 'Int',
categoryId: 'Int',
subscriptionLevelId: 'Int'
},
relationOptions: {
solfeggioFrequencyId: {
model: 'SolfeggioFrequency',
labelField: 'name',
valueField: 'id'
},
parentCategoryId: {
model: 'ProclamationCategory',
labelField: 'name',
valueField: 'id'
},
categoryId: {
model: 'ProclamationCategory',
labelField: 'name',
valueField: 'id'
},
subscriptionLevelId: {
model: 'SubscriptionLevel',
labelField: 'name',
valueField: 'id'
}
}
},
{
slug: 'solfeggio-frequencies',
modelName: 'SolfeggioFrequency',
displayName: 'Solfeggio Frequencies',
displayNameSingular: 'Solfeggio Frequency',
idType: 'Int',
include: { proclamations: true },
listFields: ['name', 'value'],
searchFields: ['name'],
formFields: ['name', 'value'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String',
value: 'Int'
},
relatedRecords: [
{
relationKey: 'proclamations',
label: 'Proclamations',
type: 'has-many',
displayFields: ['name'],
editSlug: 'proclamations'
}
]
},
{
slug: 'subscription-levels',
modelName: 'SubscriptionLevel',
displayName: 'Subscription Levels',
displayNameSingular: 'Subscription Level',
idType: 'Int',
include: { proclamations: true },
listFields: ['id', 'name', 'description'],
searchFields: ['name', 'description'],
formFields: ['name', 'description'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String',
description: 'String'
},
relatedRecords: [
{
relationKey: 'proclamations',
label: 'Proclamations',
type: 'has-many',
displayFields: ['name'],
editSlug: 'proclamations'
}
]
},
{
slug: 'proclamation-categories',
modelName: 'ProclamationCategory',
displayName: 'Proclamation Categories',
displayNameSingular: 'Proclamation Category',
idType: 'Int',
include: { proclamations: true, children: true },
listFields: ['name'],
searchFields: ['name'],
formFields: ['name', 'parentId'],
readonlyFields: ['id'],
fieldTypes: {
name: 'String',
parentId: 'Int',
sortOrder: 'Int'
},
relationOptions: {
parentId: { model: 'ProclamationCategory', labelField: 'name', valueField: 'id' }
},
relatedRecords: [
{
relationKey: 'proclamations',
label: 'Proclamations',
type: 'has-many',
displayFields: ['name'],
editSlug: 'proclamations'
},
{
relationKey: 'children',
label: 'Sub-categories',
type: 'has-many',
displayFields: ['name'],
editSlug: 'proclamation-categories'
}
]
},
{
slug: 'license-keys',
modelName: 'LicenseKey',
displayName: 'License Keys',
displayNameSingular: 'License Key',
idType: 'String',
include: { user: true },
listFields: ['key', 'active', 'level', 'user.name'],
searchFields: ['key', 'level'],
formFields: ['active', 'level', 'userId'],
readonlyFields: ['id'],
fieldTypes: {
key: 'String',
active: 'Boolean',
level: 'String',
userId: 'String'
},
relationOptions: {
userId: { model: 'User', labelField: 'name', valueField: 'id' }
}
}
];
const modelsBySlug = new Map<string, AdminModelConfig>();
for (const model of models) {
modelsBySlug.set(model.slug, model);
}
export function getModelConfig(slug: string): AdminModelConfig | undefined {
return modelsBySlug.get(slug);
}
export function getAllModelConfigs(): AdminModelConfig[] {
return models;
}
export type SerializableModelConfig = {
slug: string;
displayName: string;
listFields: string[];
accessFilterResult: Record<string, unknown> | undefined;
};
export function getSerializableModelConfigs(
viewerRoleId: number,
excludeSlugs?: string[]
): SerializableModelConfig[] {
return models
.filter((m) => !excludeSlugs?.includes(m.slug))
.map((m) => ({
slug: m.slug,
displayName: m.displayName,
listFields: m.listFields,
accessFilterResult: m.accessFilter?.(viewerRoleId)
}));
}
-15
View File
@@ -1,15 +0,0 @@
export const ROLES = {
SUPER: 1,
ADMIN: 2,
STAFF: 3,
USER: 4
} as const;
export function hasRole(userRoleId: number | null | undefined, requiredRoleId: number): boolean {
if (userRoleId == null || userRoleId < 1) return false;
return userRoleId <= requiredRoleId;
}
export function canAccessAdmin(userRoleId: number | null | undefined): boolean {
return hasRole(userRoleId, ROLES.STAFF);
}
-74
View File
@@ -1,74 +0,0 @@
import { z } from 'zod';
const email = z.string().email('Invalid email address');
const phone = z
.string()
.min(10, 'Phone number must be at least 10 digits')
.regex(/^[\d\-\+\(\) ]+$/, 'Invalid phone number format');
const name = z.string().min(1, 'Required').max(100, 'Too long');
export const UserRole = z.object({
name: z.string().min(1, 'Required')
});
export const User = z.object({
name: z.string().min(1, 'Required'),
email,
roleId: z.coerce.number().int().min(1)
});
export const Profile = z.object({
userId: z.string().min(1, 'Required'),
firstName: name,
lastName: name,
phoneNumber: phone
});
export const Address = z.object({
profileId: z.string().min(1),
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(1, 'State is required'),
postalCode: z.string().min(1, 'Postal code is required'),
country: z.string().min(1, 'Country is required'),
type: z.enum(['Mailing', 'Billing', 'Shipping']).default('Mailing'),
isDefaultMailing: z.coerce.boolean().default(false),
isDefaultBilling: z.coerce.boolean().default(false),
isDefaultShipping: z.coerce.boolean().default(false)
});
export const SolfeggioFrequency = z.object({
name: z.string().min(1, 'Required'),
value: z.coerce.number().int('Must be a whole number')
});
export const SubscriptionLevel = z.object({
name: z.string().min(1, 'Required'),
description: z.string().optional().default('')
});
export const ProclamationCategory = z.object({
name: z.string().min(1, 'Required'),
parentId: z.coerce.number().int().optional()
});
export const Proclamation = z.object({
name: z.string().min(1, 'Required'),
solfeggioFrequencyId: z.coerce.number().int(),
parentCategoryId: z.coerce.number().int(),
categoryId: z.coerce.number().int().optional(),
subscriptionLevelId: z.coerce.number().int(),
content: z.string().optional().default('')
});
export const LicenseKey = z.object({
active: z.coerce.boolean(),
level: z.string().min(1, 'Required'),
userId: z.string().min(1, 'Required')
});
export const UserUpdate = z.object({
id: z.string().min(1),
name: z.string().min(1, 'Required').optional(),
email: z.string().email().optional()
});
-47
View File
@@ -1,47 +0,0 @@
export type PrismaScalarType = 'String' | 'Int' | 'Float' | 'Boolean' | 'DateTime' | 'Tiptap';
export type RelationOptionConfig = {
model: string;
labelField: string;
valueField: string;
};
export type AdminField = {
name: string;
label?: string;
};
export type RelatedRecordConfig = {
relationKey: string;
label: string;
type: 'has-one' | 'has-many';
displayFields: string[];
editSlug?: string;
};
export type ActionResult = {
success: boolean;
error?: string;
fieldErrors?: Record<string, string>;
};
export type AdminModelConfig = {
slug: string;
modelName: string;
displayName: string;
displayNameSingular: string;
idType: 'String' | 'Int';
include?: Record<string, boolean | { include: Record<string, boolean> }>;
listFields: string[];
searchFields: string[];
formFields: string[];
readonlyFields?: string[];
listOnly?: boolean;
fieldTypes: Record<string, PrismaScalarType>;
relationOptions?: Record<string, RelationOptionConfig>;
relatedRecords?: RelatedRecordConfig[];
accessFilter?: (viewerRoleId: number) => Record<string, unknown> | undefined;
/** Fields that should be excluded from Prisma create/update (virtual/UI-only) */
dataOnlyFields?: string[];
cascadeFields?: Record<string, { parentField: string }>;
};
+14 -4
View File
@@ -51,6 +51,7 @@ export function mockUserWithProfile(overrides?: Record<string, unknown>) {
lastName: 'Doe',
phoneNumber: '555-0100'
},
profiles: [{ id: 'profile-1', userId: 'user-1' }],
...overrides
});
}
@@ -76,6 +77,7 @@ export function mockUserWithProfileAndAddresses(overrides?: Record<string, unkno
}
]
},
profiles: [{ id: 'profile-1', userId: 'user-1' }],
...overrides
});
}
@@ -100,6 +102,7 @@ export function mockProclamation(overrides?: Record<string, unknown>) {
content: '<p>Test content</p>',
solfeggioFrequencyId: 1,
categoryId: 1,
groupId: 1,
price: 0.99,
solfeggioFrequency: {
id: 1,
@@ -108,9 +111,7 @@ export function mockProclamation(overrides?: Record<string, unknown>) {
},
category: {
id: 1,
name: 'Healing',
parentId: null,
parent: null
name: 'Healing'
},
...overrides
};
@@ -121,7 +122,16 @@ export function mockCategory(overrides?: Record<string, unknown>) {
return {
id: 1,
name: 'Healing',
parentId: null,
...overrides
};
}
/** Minimal proclamation group. */
export function mockGroup(overrides?: Record<string, unknown>) {
return {
id: 1,
name: 'General',
sortOrder: 0,
...overrides
};
}
@@ -8,20 +8,18 @@ export const load: PageServerLoad = async ({ locals }) => {
throw error(403, 'not authorized');
}
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
include: {
profile: {
include: { addresses: true }
}
}
});
const user = await prisma.user.findUnique({ where: { id: locals.user.id } });
if (!user) {
throw error(403, 'not authorized');
}
return { user };
const profile = await prisma.profile.findFirst({
where: { userId: user.id },
include: { addresses: true }
});
return { user: { ...user, profile } };
};
export const actions: Actions = {
@@ -40,7 +38,7 @@ export const actions: Actions = {
}
try {
await prisma.profile.update({
await prisma.profile.updateMany({
where: { userId: locals.user.id },
data: { firstName, lastName, phoneNumber }
});
@@ -67,7 +65,7 @@ export const actions: Actions = {
return fail(400, { error: 'All address fields are required.', action: 'addAddress' });
}
const profile = await prisma.profile.findUnique({
const profile = await prisma.profile.findFirst({
where: { userId: locals.user.id },
select: { id: true }
});
@@ -23,67 +23,58 @@
}
</script>
<div class="flex h-full min-h-0 flex-col gap-8 p-6 dark:text-gray-100">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Account</h1>
<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>
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">Email</p>
<p class="mt-1 text-gray-900 dark:text-gray-100">{data.user.email}</p>
<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>
<h2 class="mb-4 text-lg font-medium text-gray-900 dark:text-gray-100">Profile</h2>
<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="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
<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="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
<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="flex max-w-lg flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
First Name
</label>
<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="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
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>
<label for="lastName" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Last Name
</label>
<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="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
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>
<label for="phoneNumber" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Phone Number
</label>
<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"
@@ -91,13 +82,13 @@
bind:value={phoneNumber}
oninput={handlePhoneInput}
required
class="mt-1 w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
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="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
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>
@@ -106,138 +97,112 @@
</section>
<!-- Addresses -->
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">Addresses</h2>
<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"
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
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="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
<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="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
>
<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="mb-6 max-w-lg rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex flex-col gap-3">
<div>
<label for="street" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>Street</label
>
<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="street"
name="street"
id="city"
name="city"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
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-2 gap-3">
<div>
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>City</label
>
<input
type="text"
id="city"
name="city"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
<div>
<label for="state" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>State</label
>
<input
type="text"
id="state"
name="state"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</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 class="grid grid-cols-2 gap-3">
<div>
<label
for="postalCode"
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Postal Code
</label>
<input
type="text"
id="postalCode"
name="postalCode"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</div>
<div>
<label
for="country"
class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country</label
>
<input
type="text"
id="country"
name="country"
required
value="US"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 dark:border-gray-600"
/>
</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>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600"
>
Save Address
</button>
<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="flex flex-col gap-3">
{#each data.user.profile.addresses as address}
<div
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"
>
<p class="text-sm text-gray-900 dark:text-gray-100">{address.street}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
<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-gray-600 dark:text-gray-400">{address.country}</p>
<p class="text-sm text-slate-600">{address.country}</p>
</div>
{/each}
</div>
{:else if !showAddressForm}
<p class="text-sm text-gray-500 dark:text-gray-400">No addresses on file.</p>
<p class="mt-5 text-sm text-slate-500">No addresses on file.</p>
{/if}
</section>
</div>
@@ -10,7 +10,7 @@ import {
vi.mock('$lib/db', () => ({
prisma: {
user: { findUnique: vi.fn() },
profile: { update: vi.fn(), findUnique: vi.fn() },
profile: { findFirst: vi.fn(), updateMany: vi.fn() },
address: { create: vi.fn() }
}
}));
@@ -19,8 +19,8 @@ import { prisma } from '$lib/db';
import { load, actions } from './+page.server';
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const profileUpdate = prisma.profile.update as ReturnType<typeof vi.fn>;
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
const profileUpdateMany = prisma.profile.updateMany as ReturnType<typeof vi.fn>;
const addressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
const invokeAction = (name: string, request: Request, locals: App.Locals) =>
@@ -29,8 +29,8 @@ const invokeAction = (name: string, request: Request, locals: App.Locals) =>
describe('(protected)/account page server', () => {
beforeEach(() => {
userFindUnique.mockReset();
profileUpdate.mockReset();
profileFindUnique.mockReset();
profileFindFirst.mockReset();
profileUpdateMany.mockReset();
addressCreate.mockReset();
});
@@ -51,24 +51,26 @@ describe('(protected)/account page server', () => {
});
it('returns user with profile and addresses', async () => {
const user = mockUserWithProfileAndAddresses();
const { profile, ...user } = mockUserWithProfileAndAddresses() as any;
userFindUnique.mockResolvedValue(user);
profileFindFirst.mockResolvedValue(profile);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.user).toEqual(user);
expect(result.user).toEqual({ ...user, profile });
expect(result.user.profile.addresses).toHaveLength(1);
});
it('includes addresses in the query', async () => {
userFindUnique.mockResolvedValue(mockUserWithProfileAndAddresses());
it('loads the profile with addresses by user id', async () => {
userFindUnique.mockResolvedValue(mockUser());
profileFindFirst.mockResolvedValue({ id: 'profile-1' });
const locals = createLocals(mockUser() as any);
await load({ locals } as any);
expect(userFindUnique).toHaveBeenCalledWith({
where: { id: 'user-1' },
include: { profile: { include: { addresses: true } } }
expect(profileFindFirst).toHaveBeenCalledWith({
where: { userId: 'user-1' },
include: { addresses: true }
});
});
});
@@ -125,7 +127,7 @@ describe('(protected)/account page server', () => {
});
it('updates profile with server-formatted phone number on success', async () => {
profileUpdate.mockResolvedValue({});
profileUpdateMany.mockResolvedValue({});
const request = createFormRequest({
firstName: 'Jane',
@@ -136,14 +138,14 @@ describe('(protected)/account page server', () => {
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(result).toEqual({ success: true, action: 'updateProfile' });
expect(profileUpdate).toHaveBeenCalledWith({
expect(profileUpdateMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
data: { firstName: 'Jane', lastName: 'Smith', phoneNumber: '8175551234' }
});
});
it('returns fail(500) when prisma update throws', async () => {
profileUpdate.mockRejectedValue(new Error('db error'));
profileUpdateMany.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
firstName: 'Jane',
@@ -190,7 +192,7 @@ describe('(protected)/account page server', () => {
});
it('throws 400 when profile is not found', async () => {
profileFindUnique.mockResolvedValue(null);
profileFindFirst.mockResolvedValue(null);
const request = createFormRequest({
street: '123 Main St',
@@ -208,7 +210,7 @@ describe('(protected)/account page server', () => {
});
it('creates address linked to profile on success', async () => {
profileFindUnique.mockResolvedValue({ id: 'profile-1' });
profileFindFirst.mockResolvedValue({ id: 'profile-1' });
addressCreate.mockResolvedValue({});
const request = createFormRequest({
@@ -235,7 +237,7 @@ describe('(protected)/account page server', () => {
});
it('returns fail(500) when prisma create throws', async () => {
profileFindUnique.mockResolvedValue({ id: 'profile-1' });
profileFindFirst.mockResolvedValue({ id: 'profile-1' });
addressCreate.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
@@ -42,41 +42,74 @@
</script>
<Window>
<form method="POST" class="border p-6 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100">
<p class="mx-auto my-4 text-center italic">Please finish setting up your profile</p>
<form method="POST" class="flex flex-col items-center gap-5">
<h1 class="text-center text-2xl font-bold text-slate-900">
Please finish setting up your profile
</h1>
{#if data.user?.image}
<img src={data.user?.image} class="mx-auto w-[8rem] rounded-full" alt="Profile" />
<img
src={data.user?.image}
alt="Profile"
class="h-24 w-24 rounded-full object-cover shadow-sm sm:h-28 sm:w-28"
/>
{:else}
<div class="mx-auto aspect-square w-[8rem] rounded-full bg-slate-900"></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"
>
{data.user?.name?.charAt(0)?.toUpperCase() ?? '?'}
</div>
{/if}
<p class="mx-auto my-4 text-center">{data.user?.email}</p>
<div class="flex flex-col">
<input type="text" name="userId" value={data.user?.id} class="hidden" />
<input
type="text"
name="firstName"
placeholder="First Name"
class="my-2"
bind:value={firstName}
/>
<input
type="text"
name="lastName"
placeholder="Last Name"
class="my-2"
bind:value={lastName}
/>
<input
type="tel"
name="phoneNumber"
placeholder="Phone Number"
class="my-2"
bind:value={phoneNumber}
oninput={handlePhoneInput}
/>
<p class="text-base text-slate-900">{data.user?.email}</p>
<div class="flex w-full flex-col gap-4">
<input type="hidden" name="userId" value={data.user?.id} />
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="flex flex-col gap-1.5">
<label for="firstName" class="text-sm font-medium text-slate-700"> First Name </label>
<input
id="firstName"
type="text"
name="firstName"
placeholder="First Name"
bind:value={firstName}
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
<div class="flex flex-col gap-1.5">
<label for="lastName" class="text-sm font-medium text-slate-700"> Last Name </label>
<input
id="lastName"
type="text"
name="lastName"
placeholder="Last Name"
bind:value={lastName}
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</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
id="phoneNumber"
type="tel"
name="phoneNumber"
placeholder="(555) 555-5555"
bind:value={phoneNumber}
oninput={handlePhoneInput}
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/>
</div>
</div>
<button type="submit" class="bg-blue-300 p-2 px-4 dark:bg-gray-700 dark:text-gray-100"
>Submit</button
<button
type="submit"
class="mt-1 flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700"
>
Submit
</button>
</form>
</Window>
@@ -7,19 +7,16 @@ export const load: PageServerLoad = async ({ locals }) => {
throw error(403, 'not authorized');
}
const profile = await prisma.profile.findUnique({
const profile = await prisma.profile.findFirst({
where: { userId: locals.user.id },
select: { firstName: true }
});
const [keyStats, proclamationCount] = await Promise.all([
prisma.licenseKey.groupBy({
by: ['active'],
where: { userId: locals.user.id },
_count: true
}),
prisma.proclamation.count()
]);
const keyStats = await prisma.licenseKey.groupBy({
by: ['active'],
where: { userId: locals.user.id },
_count: true
});
const activeKeys = keyStats.find((s) => s.active)?._count ?? 0;
const totalKeys = keyStats.reduce((sum, s) => sum + s._count, 0);
@@ -27,7 +24,6 @@ export const load: PageServerLoad = async ({ locals }) => {
return {
firstName: profile?.firstName ?? 'User',
activeKeys,
totalKeys,
proclamationCount
totalKeys
};
};
@@ -1,42 +1,33 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6 dark:text-gray-100">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Welcome, {data.firstName}!
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base">Here's an overview of your account.</p>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<a
href="/license-keys"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
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"
>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Active License Keys</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">{data.activeKeys}</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">{data.totalKeys} total</p>
<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="/admin/proclamations"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
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"
>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Proclamations</p>
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">
{data.proclamationCount}
</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Browse all</p>
</a>
<a
href="/account"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
>
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Account</p>
<p class="mt-2 text-lg font-semibold text-gray-900 dark:text-gray-100">Manage Profile</p>
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Edit your details</p>
<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>
@@ -4,24 +4,21 @@ import { createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
profile: { findUnique: vi.fn() },
licenseKey: { groupBy: vi.fn() },
proclamation: { count: vi.fn() }
profile: { findFirst: vi.fn() },
licenseKey: { groupBy: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType<typeof vi.fn>;
const proclamationCount = prisma.proclamation.count as ReturnType<typeof vi.fn>;
describe('(protected)/dashboard page server load', () => {
beforeEach(() => {
profileFindUnique.mockReset();
profileFindFirst.mockReset();
licenseKeyGroupBy.mockReset();
proclamationCount.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
@@ -32,9 +29,8 @@ describe('(protected)/dashboard page server load', () => {
});
it('returns firstName from profile', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
@@ -42,9 +38,8 @@ describe('(protected)/dashboard page server load', () => {
});
it('defaults firstName to "User" when profile is null', async () => {
profileFindUnique.mockResolvedValue(null);
profileFindFirst.mockResolvedValue(null);
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
@@ -52,12 +47,11 @@ describe('(protected)/dashboard page server load', () => {
});
it('computes activeKeys and totalKeys from groupBy results', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([
{ active: true, _count: 3 },
{ active: false, _count: 2 }
]);
proclamationCount.mockResolvedValue(10);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
@@ -66,23 +60,12 @@ describe('(protected)/dashboard page server load', () => {
});
it('returns 0 for activeKeys when none are active', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([{ active: false, _count: 4 }]);
proclamationCount.mockResolvedValue(0);
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);
});
it('returns proclamationCount', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(42);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.proclamationCount).toBe(42);
});
});
@@ -4,47 +4,35 @@
let { data }: PageProps = $props();
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6 dark:text-gray-100">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">License Keys</h1>
<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="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<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 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Key
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Level
</th>
<th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
>
Status
</th>
<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-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
{#each data.licenseKeys as key}
<tr>
<td
class="px-6 py-4 font-mono text-sm whitespace-nowrap text-gray-900 dark:text-gray-100"
>
{key.key}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300">
{key.level}
</td>
<td class="px-6 py-4 text-sm whitespace-nowrap">
<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 rounded-full px-2 text-xs leading-5 font-semibold {key.active
? 'bg-green-100 text-green-800 dark:bg-gray-700 dark:text-gray-200'
: 'bg-red-100 text-red-800 dark:bg-gray-700 dark:text-gray-200'}"
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>
@@ -53,10 +41,26 @@
{/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="flex flex-1 items-center justify-center">
<p class="text-lg text-gray-500 dark:text-gray-400">You don't have any license keys yet.</p>
<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>
+2 -2
View File
@@ -9,12 +9,12 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { id: true, roleId: true, profile: { select: { id: true } } }
select: { id: true, roleId: true, profiles: { select: { id: true } } }
});
if (!user) {
throw error(403, 'not authorized');
}
if (!user.profile && !url.pathname.startsWith('/account/setup')) {
if (user.profiles.length === 0 && !url.pathname.startsWith('/account/setup')) {
// Skip redirect when already on /account/setup to avoid redirect loop
throw redirect(303, '/account/setup');
}
+5 -68
View File
@@ -1,74 +1,11 @@
<script lang="ts">
import { page } from '$app/state';
import { canAccessAdmin } from '$lib/svelte-admin/roles';
import { getAllModelConfigs } from '$lib/svelte-admin/registry';
import type { LayoutData } from './$types';
import type { Snippet } from 'svelte';
let { children, data } = $props<{ children: any; data: LayoutData }>();
const isAdmin = $derived(canAccessAdmin(data.userRoleId));
const adminModels = getAllModelConfigs();
const navLinks = [
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/account', label: 'Account' },
{ href: '/proclamations', label: 'Proclamations' },
{ href: '/license-keys', label: 'License Keys' }
];
function isActive(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
}
let { children }: { children: Snippet } = $props();
</script>
<div class="flex min-h-0 flex-1">
<!-- Sidebar -->
<nav class="mt-12 mr-12 flex flex-col gap-1">
{#each navLinks as link}
<a
href={link.href}
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive(link.href)
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
{link.label}
</a>
{/each}
{#if isAdmin}
<div class="mt-4 flex flex-col gap-1">
<a
href="/admin"
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive('/admin')
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
Admin Panel
</a>
<div class="ml-3 flex flex-col gap-0.5">
{#each adminModels as model}
<a
href="/admin/{model.slug}"
class="rounded px-3 py-1 text-sm transition-colors {isActive('/admin/' + model.slug)
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
{model.displayName}
</a>
{/each}
<a
href="/admin/api-keys"
class="rounded px-3 py-1 text-sm transition-colors {isActive('/admin/api-keys')
? 'bg-blue-50 text-blue-700 dark:bg-gray-800 dark:text-gray-100'
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100'}"
>
API Keys
</a>
</div>
</div>
{/if}
</nav>
<!-- Page content -->
<div class="min-h-0 flex-1 overflow-auto">
<div class="min-h-screen bg-slate-50 text-slate-900">
<main class="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6 sm:py-8">
{@render children()}
</div>
</main>
</div>
+4 -9
View File
@@ -4,22 +4,17 @@
</script>
<Window>
<div class="flex flex-col items-center gap-4 p-6 text-center">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">
<div>
<h1>
Admin Error ({$page.status})
</h1>
<p class="max-w-md text-sm text-gray-600 dark:text-gray-400">
<p>
{#if $page.error?.message}
{$page.error.message}
{:else}
An unexpected error occurred while loading this admin page.
{/if}
</p>
<a
href="/admin"
class="mt-2 border border-gray-200 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800"
>
Back to Admin
</a>
<a href="/admin"> Back to Admin </a>
</div>
</Window>
@@ -1,7 +1,6 @@
import type { LayoutServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { canAccessAdmin } from '$lib/svelte-admin/roles';
/**
* This Layout Server-Side page checks if the user is logged in
@@ -17,7 +16,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
select: { id: true, name: true, roleId: true, role: { select: { name: true } } }
});
if (!user || !canAccessAdmin(user.roleId)) {
if (!user || !user.roleId || user.roleId > 3) {
console.log(
`Attempted to access /admin via ${user?.id} : ${user?.name} with a role of ${user?.role}`
);
+3 -6
View File
@@ -1,8 +1,5 @@
import type { PageServerLoad } from './$types';
import { getSerializableModelConfigs } from '$lib/svelte-admin/registry';
import { redirect } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const models = getSerializableModelConfigs(viewerRoleId, ['license-keys']);
return { models };
export const load = () => {
redirect(307, '/admin/profiles');
};
-21
View File
@@ -1,21 +0,0 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let models = $derived(data.models);
</script>
<h1 class="p-6 text-xl font-bold text-gray-900 dark:text-gray-100">Admin Dashboard</h1>
<div class="grid grid-cols-1 gap-4 px-6 pb-6 sm:grid-cols-2 lg:grid-cols-3">
{#each models as model (model.slug)}
<a
href={'/admin/' + model.slug}
class="block border border-gray-200 p-4 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
>
<h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">{model.displayName}</h2>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{model.listFields.length} fields
</p>
</a>
{/each}
</div>
@@ -2,11 +2,12 @@ import type { PageServerLoad } from './$types';
import { error } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { rotateApiKey } from '$lib/server/api-key';
import { ROLES } from '$lib/svelte-admin/roles';
const SUPER = 1;
export const load: PageServerLoad = async ({ locals, parent }) => {
const { adminUser } = await parent();
if (adminUser.roleId > ROLES.SUPER) {
if (adminUser.roleId > SUPER) {
throw error(403, 'Only Super admins can manage API keys');
}
@@ -49,7 +50,7 @@ async function viewerRoleId(locals: App.Locals): Promise<number> {
export const actions = {
rotate: async ({ locals }) => {
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
if ((await viewerRoleId(locals)) > SUPER) {
return { success: false, error: 'Not authorized' };
}
@@ -62,7 +63,7 @@ export const actions = {
};
},
rotateEmergency: async ({ locals }) => {
if ((await viewerRoleId(locals)) > ROLES.SUPER) {
if ((await viewerRoleId(locals)) > SUPER) {
return { success: false, error: 'Not authorized' };
}
@@ -8,7 +8,6 @@
let mode = $state<'normal' | 'emergency' | null>(null);
let expiryDate = $state<string | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
function handleRotate(emergency: boolean) {
return ({ cancel }: any) => {
@@ -29,7 +28,6 @@
}
} else {
toastMessage = 'Rotation failed';
toastType = 'error';
}
};
};
@@ -38,7 +36,6 @@
function copyKey() {
navigator.clipboard.writeText(newKey);
toastMessage = 'Copied to clipboard';
toastType = 'success';
}
function dismissKey() {
@@ -48,44 +45,45 @@
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">API Key Management</h1>
<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-6 border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-900/30"
>
<p class="mb-2 text-sm font-semibold text-amber-800 dark:text-amber-300">
<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="mb-1 text-xs text-amber-700 dark:text-amber-400">
This key will not be shown again. Copy it now.
</p>
<div class="mb-3">
<code
class="rounded border border-amber-300 bg-white px-3 py-2 font-mono text-sm break-all dark:border-amber-600 dark:bg-gray-900 dark:text-gray-100"
>
{newKey}
</code>
<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="flex gap-2">
<div class="mt-3 flex gap-2">
<button
type="button"
onclick={copyKey}
class="border border-amber-300 bg-white px-3 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-600 dark:bg-gray-800 dark:text-amber-300 dark:hover:bg-amber-900/50"
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="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
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-amber-700 dark:text-amber-400">
<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>
@@ -95,22 +93,18 @@
{#if toastMessage}
<div
class="mb-4 border px-4 py-2 text-sm {toastType === 'success'
? 'border-green-300 bg-green-50 text-green-700'
: 'border-red-300 bg-red-50 text-red-700'}"
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-6 rounded border border-gray-200 p-4 dark:border-gray-700">
<h2 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">Current Key Status</h2>
<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="space-y-1 text-sm text-gray-600 dark:text-gray-400">
<div class="mt-3 space-y-1 text-sm text-slate-700">
<div>
Prefix: <code class="font-mono font-medium text-gray-900 dark:text-gray-100"
>{data.activeKey.prefix}</code
>
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}
@@ -118,20 +112,20 @@
{/if}
</div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">No active API key. Generate one below.</p>
<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-xs text-gray-400">
<p class="mt-2 text-sm text-slate-600">
{data.totalRotated} previous key(s) have been rotated.
</p>
{/if}
</div>
<div class="flex flex-wrap gap-3">
<div class="flex flex-col gap-3 sm:flex-row">
<form method="POST" action="?/rotate" use:enhance={handleRotate(false)}>
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-4 py-2 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
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>
@@ -139,7 +133,7 @@
<form method="POST" action="?/rotateEmergency" use:enhance={handleRotate(true)}>
<button
type="submit"
class="border border-red-300 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-100 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50"
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>
@@ -1,27 +1,19 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords } from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { sendMail } from '$lib/server/resend';
import { error } from '@sveltejs/kit';
const config = getModelConfig('license-keys')!;
function generateLicenseKey(): string {
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
}
export const load: PageServerLoad = async ({ locals }) => {
const records = await loadRecords(locals, config);
const subscriptionLevels = await prisma.subscriptionLevel.findMany({
select: { id: true, name: true },
orderBy: { name: 'asc' }
export const load: PageServerLoad = async () => {
const records = await prisma.licenseKey.findMany({
include: { user: true },
orderBy: { key: 'asc' }
});
return {
...records,
subscriptionLevels: subscriptionLevels.map((s: { id: number; name: string }) => ({
label: s.name,
value: s.name
}))
};
return { records: records as unknown as Record<string, unknown>[] };
};
export const actions = {
@@ -29,41 +21,35 @@ export const actions = {
if (!locals.user) throw error(401, 'Unauthorized');
const formData = await request.formData();
const email = formData.get('email')?.toString();
const level = formData.get('level')?.toString();
const email = formData.get('email')?.toString()?.trim().toLowerCase();
if (!email) {
return { success: false, error: 'Email is required' };
}
if (!level) {
return { success: false, error: 'Subscription level is required' };
return { success: false, action: 'createKey', error: 'Email is required' };
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return { success: false, error: 'No user found with that email' };
}
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
const key = `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
const key = generateLicenseKey();
await prisma.licenseKey.create({
data: {
key,
active: true,
level,
userId: user.id
userId: user?.id,
email: user ? undefined : email
}
});
try {
const profile = await prisma.profile.findUnique({ where: { userId: user.id } });
const profile = await prisma.profile.findFirst({
where: user ? { userId: user.id } : { email }
});
await sendMail({
to: email,
subject: 'Your Rhemawave License Key',
subject: 'Your Radiant Wave License Key',
templateId: 'license-key-delivery',
data: {
firstName: profile?.firstName ?? user.name ?? 'Valued Customer',
firstName: profile?.firstName ?? user?.name ?? 'Valued Customer',
license_key: key
}
});
@@ -71,32 +57,29 @@ export const actions = {
console.error('Failed to send license key email:', e);
}
return { success: true, error: undefined };
return { success: true, action: 'createKey' };
},
replace: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const formData = await request.formData();
const keyId = formData.get('id')?.toString();
if (!keyId) return { success: false, error: 'Missing key id' };
if (!keyId) return { success: false, action: 'replace', error: 'Missing key id' };
const existing = await prisma.licenseKey.findUnique({ where: { id: keyId } });
if (!existing) return { success: false, error: 'License key not found' };
if (!existing) return { success: false, action: 'replace', error: 'License key not found' };
await prisma.licenseKey.delete({ where: { id: keyId } });
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
const key = `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
await prisma.licenseKey.create({
data: {
key,
key: generateLicenseKey(),
active: true,
level: existing.level,
userId: existing.userId
userId: existing.userId,
email: existing.email
}
});
return { success: true, error: undefined };
return { success: true, action: 'replace' };
}
};
@@ -1,28 +1,90 @@
<script lang="ts">
import type { PageProps } from './$types';
import { enhance } from '$app/forms';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import type { PageProps } from './$types';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import LicenseKeyForm from './LicenseKeyForm.svelte';
let { data }: PageProps = $props();
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
} | null;
const config = getModelConfig('license-keys')!;
let { data, form }: PageProps = $props();
const columns: DataTableColumn[] = [
{ label: 'Key', value: (r) => r.key },
{
label: 'User',
value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? ''
},
{
label: 'Email',
value: (r) => (r.user as Record<string, unknown> | undefined)?.email ?? r.email ?? ''
},
{ label: 'Active', value: (r) => (r.active ? 'Yes' : 'No') }
];
let createOpen = $state(false);
let replaceOpen = $state(false);
let replaceRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let email = $state('');
let createError = $state('');
let createSuccess = $state('');
let replaceError = $state('');
let replaceSuccess = $state('');
let lastCreateForm = $state<ActionResult>(null);
let lastReplaceForm = $state<ActionResult>(null);
$effect(() => {
if (!form) return;
if (form.action === 'createKey') {
if (form === lastCreateForm) return;
lastCreateForm = form;
if (form.success) {
email = '';
createError = '';
createSuccess = 'License key created and emailed';
setTimeout(() => {
createOpen = false;
createSuccess = '';
}, 1200);
} else {
createSuccess = '';
createError = form.error ?? '';
}
} else if (form.action === 'replace') {
if (form === lastReplaceForm) return;
lastReplaceForm = form;
if (form.success) {
replaceError = '';
replaceSuccess = 'License key replaced';
setTimeout(() => {
replaceOpen = false;
replaceSuccess = '';
}, 1200);
} else {
replaceSuccess = '';
replaceError = form.error ?? '';
}
}
});
function openCreate() {
lastCreateForm = null;
createError = '';
createSuccess = '';
createOpen = true;
}
function openReplace(record: Record<string, unknown>) {
replaceRecord = record;
lastReplaceForm = null;
replaceError = '';
replaceSuccess = '';
replaceOpen = true;
}
@@ -34,98 +96,111 @@
replaceOpen = false;
replaceRecord = null;
}
function replaceEnhance() {
return async ({ result, update }: any) => {
if (result.type === 'success' && result.status === 200) {
closeReplace();
toastMessage = 'License key replaced';
toastType = 'success';
} else {
toastMessage = 'Failed to replace license key';
toastType = 'error';
}
await update();
};
}
</script>
<div class="p-6">
<div class="mb-4 flex items-center justify-between">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<button
type="button"
onclick={openCreate}
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300"
>
New License Key +
</button>
</div>
<AdminTable
<div>
<DataTable
title="License Keys"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New License Key"
searchPlaceholder="Search license keys..."
copyableFields={['key']}
onRowClick={openReplace}
onNew={openCreate}
/>
</div>
<AdminModal title="Create License Key" open={createOpen} onClose={closeCreate}>
<LicenseKeyForm subscriptionLevels={data.subscriptionLevels} onSuccess={closeCreate} />
{#if createSuccess}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{createSuccess}
</div>
{/if}
{#if createError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{createError}
</div>
{/if}
<form method="POST" action="?/createKey" use:enhance class="flex flex-col gap-4">
<div>
<label for="field-email" class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input
id="field-email"
type="email"
name="email"
required
bind:value={email}
placeholder="user@example.com"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
<p class="mt-1 text-sm text-slate-500">
If no account exists for this email yet, the key will be attached when they register.
</p>
</div>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={closeCreate}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Create &amp; Email Key
</button>
</div>
</form>
</AdminModal>
<AdminModal title="Replace License Key" open={replaceOpen} onClose={closeReplace}>
{#if replaceSuccess}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{replaceSuccess}
</div>
{/if}
{#if replaceError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{replaceError}
</div>
{/if}
{#if replaceRecord}
<div class="flex flex-col gap-4">
<p class="text-sm text-gray-700 dark:text-gray-300">
<p class="text-sm text-slate-700">
Generate a new key to replace <strong>{String(replaceRecord.key)}</strong>?
</p>
<div
class="rounded border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-800"
>
<div class="mb-1 text-gray-500 dark:text-gray-400">
User: <span class="font-medium text-gray-700 dark:text-gray-300"
>{String(replaceRecord['user.name'] ?? '')}</span
>
</div>
<div class="text-gray-500 dark:text-gray-400">
Level: <span class="font-medium text-gray-700 dark:text-gray-300"
>{String(replaceRecord.level ?? '')}</span
>
</div>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
The existing key will be deleted and a new one with the same subscription level will be
created and emailed.
<p class="text-sm text-slate-500">
The existing key will be deleted and a new one will be created.
</p>
<form
method="POST"
action="?/replace"
use:enhance={replaceEnhance}
class="flex justify-end gap-2 pt-2"
>
<form method="POST" action="?/replace" use:enhance>
<input type="hidden" name="id" value={String(replaceRecord.id)} />
<button
type="button"
onclick={closeReplace}
class="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
>
Cancel
</button>
<button
type="submit"
class="border border-amber-300 bg-amber-50 px-4 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-300 dark:hover:bg-amber-900/50"
>
Generate Replacement
</button>
<div class="flex justify-end gap-3">
<button
type="button"
onclick={closeReplace}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-red-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-red-700"
>
Generate Replacement
</button>
</div>
</form>
</div>
{/if}
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -1,91 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
subscriptionLevels = [] as { label: string; value: string }[],
onSuccess
}: {
subscriptionLevels?: { label: string; value: string }[];
onSuccess?: () => void;
} = $props();
let email = $state('');
let level = $state('');
let formError = $state('');
function handleEnhance() {
return async ({ result, update }: any) => {
if (result.type === 'success' && result.status === 200) {
if (result.data?.error) {
formError = result.data.error;
} else {
email = '';
level = '';
formError = '';
await update();
onSuccess?.();
}
} else {
await update();
}
};
}
</script>
<form method="POST" action="?/createKey" use:enhance={handleEnhance}>
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-email"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
User Email
</label>
<input
type="email"
id="field-email"
name="email"
required
bind:value={email}
class="w-full border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
placeholder="user@example.com"
/>
</div>
<div>
<label
for="field-level"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Subscription Level
</label>
<select
name="level"
id="field-level"
required
onchange={(e) => (level = (e.target as HTMLSelectElement).value)}
class="w-full border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
<option value="" disabled selected={!level}>-- Select --</option>
{#each subscriptionLevels as opt (opt.value)}
<option value={opt.value} selected={opt.value === level}>{opt.label}</option>
{/each}
</select>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Create & Email Key
</button>
</div>
</div>
</form>
@@ -8,38 +8,44 @@ const { sendMail } = vi.hoisted(() => ({
vi.mock('$lib/server/resend', () => ({ sendMail }));
vi.mock('$lib/svelte-admin/registry', () => ({
getModelConfig: vi.fn(() => ({ slug: 'license-keys', listFields: [], searchFields: [] }))
}));
vi.mock('$lib/svelte-admin/crud', () => ({
loadRecords: vi.fn().mockResolvedValue({ records: [], relationOptionsData: {} })
}));
vi.mock('$lib/db', () => ({
prisma: {
user: { findUnique: vi.fn() },
profile: { findUnique: vi.fn() },
licenseKey: { create: vi.fn() },
subscriptionLevel: { findMany: vi.fn() }
profile: { findFirst: vi.fn() },
licenseKey: { findMany: vi.fn(), findUnique: vi.fn(), create: vi.fn(), delete: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { actions } from './+page.server';
import { load, actions } from './+page.server';
const mockFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockProfileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const mockProfileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
const mockLicenseKeyCreate = prisma.licenseKey.create as ReturnType<typeof vi.fn>;
const mockLicenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const mockLicenseKeyDelete = prisma.licenseKey.delete as ReturnType<typeof vi.fn>;
describe('admin license-keys createKey action', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns records via load', async () => {
const records = [{ id: 'k1', key: 'AAAA-BBBB-CCCC-DDDD' }];
(prisma.licenseKey.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(records);
const result = await load({} as any);
expect(prisma.licenseKey.findMany).toHaveBeenCalledWith({
include: { user: true },
orderBy: { key: 'asc' }
});
expect(result).toEqual({ records });
});
it('rejects unauthenticated requests', async () => {
const locals = createLocals();
const req = createFormRequest({ email: 'jane@example.com', level: 'standard' });
const req = createFormRequest({ email: 'jane@example.com' });
const err = await actions.createKey({ request: req, locals } as any).catch((e) => e);
expect(isHttpError(err, 401)).toBe(true);
@@ -47,27 +53,36 @@ describe('admin license-keys createKey action', () => {
it('returns error when email is missing', async () => {
const locals = createLocals(mockUser() as any);
const req = createFormRequest({ level: 'standard' });
const req = createFormRequest({});
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: false, error: 'Email is required' });
expect(result).toEqual({ success: false, action: 'createKey', error: 'Email is required' });
});
it('returns error when level is missing', async () => {
const locals = createLocals(mockUser() as any);
const req = createFormRequest({ email: 'jane@example.com' });
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: false, error: 'Subscription level is required' });
});
it('returns error when user is not found', async () => {
it('creates a key with only an email when no user exists yet', async () => {
mockFindUnique.mockResolvedValue(null);
mockProfileFindFirst.mockResolvedValue(null);
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockResolvedValue({ id: 'email-1' });
const locals = createLocals(mockUser() as any);
const req = createFormRequest({ email: 'nobody@example.com', level: 'standard' });
const req = createFormRequest({ email: 'nobody@example.com' });
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: false, error: 'No user found with that email' });
expect(result).toEqual({ success: true, action: 'createKey' });
expect(mockLicenseKeyCreate).toHaveBeenCalledOnce();
const createCall = mockLicenseKeyCreate.mock.calls[0][0];
expect(createCall.data.active).toBe(true);
expect(createCall.data.userId).toBeUndefined();
expect(createCall.data.email).toBe('nobody@example.com');
expect(sendMail).toHaveBeenCalledOnce();
const mailCall = sendMail.mock.calls[0][0];
expect(mailCall.to).toBe('nobody@example.com');
expect(mailCall.templateId).toBe('license-key-delivery');
expect(mailCall.data.firstName).toBe('Valued Customer');
});
it('creates a license key and sends email with license_key variable', async () => {
@@ -75,22 +90,21 @@ describe('admin license-keys createKey action', () => {
const profile = { firstName: 'Jane' };
mockFindUnique.mockResolvedValue(user);
mockProfileFindUnique.mockResolvedValue(profile);
mockProfileFindFirst.mockResolvedValue(profile);
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockResolvedValue({ id: 'email-1' });
const locals = createLocals(user as any);
const req = createFormRequest({ email: user.email, level: 'standard' });
const req = createFormRequest({ email: user.email });
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: true, error: undefined });
expect(result).toEqual({ success: true, action: 'createKey' });
expect(mockLicenseKeyCreate).toHaveBeenCalledOnce();
const createCall = mockLicenseKeyCreate.mock.calls[0][0];
expect(createCall.data.key).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/);
expect(createCall.data.active).toBe(true);
expect(createCall.data.level).toBe('standard');
expect(createCall.data.userId).toBe('user-1');
expect(sendMail).toHaveBeenCalledOnce();
@@ -106,12 +120,12 @@ describe('admin license-keys createKey action', () => {
const user = mockUser();
mockFindUnique.mockResolvedValue(user);
mockProfileFindUnique.mockResolvedValue(null);
mockProfileFindFirst.mockResolvedValue(null);
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockResolvedValue({ id: 'email-1' });
const locals = createLocals(user as any);
const req = createFormRequest({ email: user.email, level: 'premium' });
const req = createFormRequest({ email: user.email });
await actions.createKey({ request: req, locals } as any);
@@ -125,19 +139,76 @@ describe('admin license-keys createKey action', () => {
const user = mockUser();
mockFindUnique.mockResolvedValue(user);
mockProfileFindUnique.mockResolvedValue({ firstName: 'Jane' });
mockProfileFindFirst.mockResolvedValue({ firstName: 'Jane' });
mockLicenseKeyCreate.mockResolvedValue({});
sendMail.mockRejectedValue(new Error('Resend API down'));
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const locals = createLocals(user as any);
const req = createFormRequest({ email: user.email, level: 'standard' });
const req = createFormRequest({ email: user.email });
const result = await actions.createKey({ request: req, locals } as any);
expect(result).toEqual({ success: true, error: undefined });
expect(result).toEqual({ success: true, action: 'createKey' });
expect(mockLicenseKeyCreate).toHaveBeenCalledOnce();
consoleSpy.mockRestore();
});
});
describe('admin license-keys replace action', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('rejects unauthenticated requests', async () => {
const locals = createLocals();
const req = createFormRequest({ id: 'k1' });
const err = await actions.replace({ request: req, locals } as any).catch((e) => e);
expect(isHttpError(err, 401)).toBe(true);
});
it('returns error when id is missing', async () => {
const locals = createLocals(mockUser() as any);
const req = createFormRequest({});
const result = await actions.replace({ request: req, locals } as any);
expect(result).toEqual({ success: false, action: 'replace', error: 'Missing key id' });
});
it('returns error when the key does not exist', async () => {
mockLicenseKeyFindUnique.mockResolvedValue(null);
const locals = createLocals(mockUser() as any);
const req = createFormRequest({ id: 'missing' });
const result = await actions.replace({ request: req, locals } as any);
expect(result).toEqual({ success: false, action: 'replace', error: 'License key not found' });
});
it('deletes the old key and creates a replacement preserving the owner', async () => {
mockLicenseKeyFindUnique.mockResolvedValue({
id: 'k1',
key: 'AAAA-BBBB-CCCC-DDDD',
userId: 'user-1',
email: null
});
mockLicenseKeyDelete.mockResolvedValue({});
mockLicenseKeyCreate.mockResolvedValue({});
const locals = createLocals(mockUser() as any);
const req = createFormRequest({ id: 'k1' });
const result = await actions.replace({ request: req, locals } as any);
expect(result).toEqual({ success: true, action: 'replace' });
expect(mockLicenseKeyDelete).toHaveBeenCalledWith({ where: { id: 'k1' } });
expect(mockLicenseKeyCreate).toHaveBeenCalledOnce();
const createCall = mockLicenseKeyCreate.mock.calls[0][0];
expect(createCall.data.key).toMatch(/^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/);
expect(createCall.data.active).toBe(true);
expect(createCall.data.userId).toBe('user-1');
expect(createCall.data.email).toBeNull();
});
});
@@ -1,107 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { getSerializableModelConfigs } from '$lib/svelte-admin/registry';
import { createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({}));
import { load } from './+page.server';
function localsWithRole(roleId: number) {
return createLocals(mockUser({ roleId }) as any);
}
describe('getSerializableModelConfigs', () => {
it('returns only serializable fields (no functions)', () => {
const models = getSerializableModelConfigs(1);
const serialized = JSON.stringify(models);
expect(serialized).toBeDefined();
for (const model of models) {
expect(typeof model.slug).toBe('string');
expect(typeof model.displayName).toBe('string');
expect(Array.isArray(model.listFields)).toBe(true);
expect(
typeof model.accessFilterResult === 'object' || model.accessFilterResult === undefined
).toBe(true);
}
});
it('evaluates accessFilter for SUPER (roleId 1) — returns undefined for users', () => {
const models = getSerializableModelConfigs(1);
const users = models.find((m) => m.slug === 'users')!;
expect(users.accessFilterResult).toBeUndefined();
});
it('evaluates accessFilter for ADMIN (roleId 2) — restricts users to STAFF+', () => {
const models = getSerializableModelConfigs(2);
const users = models.find((m) => m.slug === 'users')!;
expect(users.accessFilterResult).toEqual({ roleId: { gte: 3 } });
});
it('evaluates accessFilter for STAFF (roleId 3) — restricts users to USER-only', () => {
const models = getSerializableModelConfigs(3);
const users = models.find((m) => m.slug === 'users')!;
expect(users.accessFilterResult).toEqual({ roleId: { gt: 3 } });
});
it('models without accessFilter have undefined accessFilterResult', () => {
const models = getSerializableModelConfigs(1);
const roles = models.find((m) => m.slug === 'user-roles')!;
expect(roles.accessFilterResult).toBeUndefined();
});
it('excludes specified slugs', () => {
const models = getSerializableModelConfigs(1, ['license-keys', 'users']);
expect(models.find((m) => m.slug === 'license-keys')).toBeUndefined();
expect(models.find((m) => m.slug === 'users')).toBeUndefined();
expect(models.length).toBeGreaterThan(0);
});
});
describe('admin index page load', () => {
it('returns models with evaluated accessFilter results', async () => {
const result = (await load({ locals: localsWithRole(1) } as any)) as any;
expect(result.models).toBeDefined();
expect(Array.isArray(result.models)).toBe(true);
const users = result.models.find((m: any) => m.slug === 'users');
expect(users).toBeDefined();
expect(users.accessFilterResult).toBeUndefined();
});
it('returns different accessFilter results per role', async () => {
const superResult = (await load({ locals: localsWithRole(1) } as any)) as any;
const adminResult = (await load({ locals: localsWithRole(2) } as any)) as any;
const staffResult = (await load({ locals: localsWithRole(3) } as any)) as any;
const superUsers = superResult.models.find((m: any) => m.slug === 'users');
const adminUsers = adminResult.models.find((m: any) => m.slug === 'users');
const staffUsers = staffResult.models.find((m: any) => m.slug === 'users');
expect(superUsers.accessFilterResult).toBeUndefined();
expect(adminUsers.accessFilterResult).toEqual({ roleId: { gte: 3 } });
expect(staffUsers.accessFilterResult).toEqual({ roleId: { gt: 3 } });
});
it('excludes license-keys model', async () => {
const result = (await load({ locals: localsWithRole(1) } as any)) as any;
const licenseKeys = result.models.find((m: any) => m.slug === 'license-keys');
expect(licenseKeys).toBeUndefined();
});
it('returns fully JSON-serializable data (no functions)', async () => {
const result = (await load({ locals: localsWithRole(2) } as any)) as any;
const serialized = JSON.stringify(result);
const parsed = JSON.parse(serialized);
expect(parsed.models).toBeDefined();
expect(parsed.models.length).toBeGreaterThan(0);
});
it('defaults to roleId 4 when user has no roleId', async () => {
const result = (await load({
locals: createLocals(mockUser({ roleId: null }) as any)
} as any)) as any;
const users = result.models.find((m: any) => m.slug === 'users');
expect(users.accessFilterResult).toEqual({ roleId: { gt: 3 } });
});
});
@@ -1,146 +1,81 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { getDelegate } from '$lib/svelte-admin/prisma-delegate';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const config = getModelConfig('proclamation-categories')!;
const CategorySchema = z.object({
name: z.string().min(1, 'Required').max(100, 'Too long')
});
export const load: PageServerLoad = async ({ locals }) => {
const delegate = getDelegate(config);
const records = await delegate.findMany({
where: { parentId: null },
include: config.include,
orderBy: { sortOrder: 'asc' }
export const load: PageServerLoad = async () => {
const records = await prisma.proclamationCategory.findMany({
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
});
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
for (const [field, relConfig] of Object.entries(config.relationOptions ?? {})) {
const relDelegate = getDelegate({ ...config, modelName: relConfig.model });
const selectFields: Record<string, boolean> = {
[relConfig.valueField]: true,
[relConfig.labelField]: true
};
const options = await relDelegate.findMany({
select: selectFields,
orderBy: { [relConfig.labelField]: 'asc' }
});
const mapped = (options as Record<string, unknown>[]).map((opt: Record<string, unknown>) => ({
label: String(opt[relConfig.labelField]),
value: opt[relConfig.valueField] as string | number
}));
relationOptionsData[field] = mapped;
}
return {
records: records as Record<string, unknown>[],
relationOptionsData
};
return { records: records as unknown as Record<string, unknown>[] };
};
export const actions = {
upsert: async ({ request, locals }) => {
upsert: async ({ request }) => {
const formData = await request.formData();
const name = (formData.get('name')?.toString() ?? '').trim();
const rawId = formData.get('id')?.toString();
const childIds = formData.getAll('childId').map((v) => v.toString());
const childNames = formData
.getAll('childName')
.map((v) => v.toString().trim())
.filter(Boolean);
if (!name) {
return { success: false, error: 'Name is required', fieldErrors: { name: 'Required' } };
const form = await superValidate(formData, zod4(CategorySchema));
if (!form.valid) {
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const delegate = getDelegate(config);
const rawId = formData.get('id')?.toString();
const name = form.data.name;
try {
let categoryId: number;
if (rawId) {
const id = parseInt(rawId, 10);
await delegate.update({
where: { id },
await prisma.proclamationCategory.update({
where: { id: Number(rawId) },
data: { name }
});
categoryId = id;
} else {
const maxSort = await delegate.findFirst({
where: { parentId: null },
const maxSort = await prisma.proclamationCategory.findFirst({
orderBy: { sortOrder: 'desc' },
select: { sortOrder: true }
});
const sortOrder = (maxSort?.sortOrder ?? -1) + 1;
const created = await delegate.create({
data: { name, sortOrder }
await prisma.proclamationCategory.create({
data: { name, sortOrder: (maxSort?.sortOrder ?? -1) + 1 }
});
categoryId = created.id;
}
const existingChildren = await delegate.findMany({
where: { parentId: categoryId },
select: { id: true }
});
const existingChildIdSet = new Set(existingChildren.map((c: { id: number }) => c.id));
const submittedIdSet = new Set<number>();
for (let i = 0; i < childNames.length; i++) {
const childName = childNames[i];
if (!childName) continue;
const childIdRaw = childIds[i] ? parseInt(childIds[i], 10) : null;
if (childIdRaw && existingChildIdSet.has(childIdRaw)) {
submittedIdSet.add(childIdRaw);
await delegate.update({
where: { id: childIdRaw },
data: { name: childName }
});
} else {
await delegate.create({
data: { name: childName, parentId: categoryId }
});
}
}
for (const child of existingChildren) {
if (!submittedIdSet.has(child.id)) {
await delegate.delete({ where: { id: child.id } });
}
}
return { success: true };
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
error: 'A sub-category with this name already exists under this parent.'
action: 'upsert',
error: 'A category with this name already exists.'
};
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request, locals }) => {
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
const id = parseInt(rawId, 10);
const delegate = getDelegate(config);
const viewerRoleId = (locals.user as { roleId?: number } | null)?.roleId ?? 4;
const where = config.accessFilter?.(viewerRoleId);
if (where) {
const match = await delegate.findFirst({ where: { id, ...where } });
if (!match) return { success: false, error: 'Access denied' };
try {
await prisma.proclamationCategory.delete({ where: { id: Number(rawId) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return {
success: false,
error: 'Cannot delete: one or more proclamations use this category.'
};
}
throw e;
}
await delegate.deleteMany({ where: { parentId: id } });
await delegate.delete({ where: { id } });
return { success: true };
return { success: true, action: 'delete' };
},
reorder: async ({ request }) => {
const formData = await request.formData();
@@ -148,16 +83,13 @@ export const actions = {
if (!raw) return { success: false, error: 'Missing ids' };
const ids = JSON.parse(raw) as number[];
const delegate = getDelegate(config);
for (let i = 0; i < ids.length; i++) {
await delegate.update({
await prisma.proclamationCategory.update({
where: { id: ids[i] },
data: { sortOrder: i }
});
}
return { success: true };
return { success: true, action: 'reorder' };
}
};
@@ -1,36 +1,57 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import CategoryForm from './CategoryForm.svelte';
import type { PageProps } from './$types';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const config = getModelConfig('proclamation-categories')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const columns: DataTableColumn[] = [{ label: 'Name', value: (r) => r.name }];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let name = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Category updated' : 'Category created';
setTimeout(() => {
modalOpen = false;
successMessage = '';
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
@@ -43,34 +64,29 @@
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="Proclamation Categories"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Category"
searchPlaceholder="Search categories..."
draggable={true}
onReorder={handleReorder}
onRowClick={openEdit}
@@ -78,23 +94,62 @@
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<CategoryForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Save' : 'Create'}
/>
</AdminModal>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit Category' : 'Create Category'}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save Category' : 'Create Category'}
</button>
</div>
</form>
</AdminModal>
{/if}
@@ -1,124 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
action?: string;
submitLabel?: string;
} = $props();
let name = $state('');
let children = $state<{ id: number | null; name: string }[]>([]);
let dirty = $state(false);
$effect(() => {
dirty = false;
});
$effect(() => {
name = String(record.name ?? '');
const existingChildren = (record.children as Array<{ id: number; name: string }>) ?? [];
children = existingChildren.map((c) => ({ id: c.id, name: c.name }));
});
function markDirty() {
if (record.id) dirty = true;
}
function addChild() {
children = [...children, { id: null, name: '' }];
if (record.id) dirty = true;
}
function removeChild(index: number) {
children = children.filter((_, i) => i !== index);
if (record.id) dirty = true;
}
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Name
</label>
<input
type="text"
id="field-name"
name="name"
bind:value={name}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Sub-categories
</label>
{#each children as child, i}
<div class="mb-2 flex items-center gap-2">
<input type="hidden" name="childId" value={child.id ?? ''} />
<input
type="text"
name="childName"
bind:value={child.name}
placeholder="Sub-category name"
class="flex-1 border px-3 py-1.5 text-sm {fieldErrors[`childName.${i}`]
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
<button
type="button"
onclick={() => removeChild(i)}
class="shrink-0 px-2 py-1 text-sm text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
Remove
</button>
</div>
{/each}
<button
type="button"
onclick={addChild}
class="mt-1 text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
+ Add Sub-category
</button>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
@@ -0,0 +1,95 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const GroupSchema = z.object({
name: z.string().min(1, 'Required').max(100, 'Too long')
});
export const load: PageServerLoad = async () => {
const records = await prisma.proclamationGroup.findMany({
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
});
return { records: records as unknown as Record<string, unknown>[] };
};
export const actions = {
upsert: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(GroupSchema));
if (!form.valid) {
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const name = form.data.name;
try {
if (rawId) {
await prisma.proclamationGroup.update({
where: { id: Number(rawId) },
data: { name }
});
} else {
const maxSort = await prisma.proclamationGroup.findFirst({
orderBy: { sortOrder: 'desc' },
select: { sortOrder: true }
});
await prisma.proclamationGroup.create({
data: { name, sortOrder: (maxSort?.sortOrder ?? -1) + 1 }
});
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
action: 'upsert',
error: 'A group with this name already exists.'
};
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
try {
await prisma.proclamationGroup.delete({ where: { id: Number(rawId) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return {
success: false,
error: 'Cannot delete: one or more proclamations use this group.'
};
}
throw e;
}
return { success: true, action: 'delete' };
},
reorder: async ({ request }) => {
const formData = await request.formData();
const raw = formData.get('ids')?.toString();
if (!raw) return { success: false, error: 'Missing ids' };
const ids = JSON.parse(raw) as number[];
for (let i = 0; i < ids.length; i++) {
await prisma.proclamationGroup.update({
where: { id: ids[i] },
data: { sortOrder: i }
});
}
return { success: true, action: 'reorder' };
}
};
@@ -0,0 +1,155 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { invalidateAll } from '$app/navigation';
import type { PageProps } from './$types';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const columns: DataTableColumn[] = [{ label: 'Name', value: (r) => r.name }];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let name = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Group updated' : 'Group created';
setTimeout(() => {
modalOpen = false;
successMessage = '';
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
async function handleReorder(ids: (string | number)[]) {
const formData = new FormData();
formData.set('ids', JSON.stringify(ids));
await fetch('?/reorder', { method: 'POST', body: formData });
await invalidateAll();
}
function openCreate() {
editingRecord = null;
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
}
</script>
<div>
<DataTable
title="Proclamation Groups"
{columns}
records={data.records}
newLabel="New Group"
searchPlaceholder="Search groups..."
draggable={true}
onReorder={handleReorder}
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit Group' : 'Create Group'}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save Group' : 'Create Group'}
</button>
</div>
</form>
</AdminModal>
{/if}
@@ -1,78 +1,87 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, deleteRecord, superFormErrorsToFieldErrors } from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import * as Schemas from '$lib/svelte-admin/schemas';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const config = getModelConfig('proclamations')!;
function viewerRoleId(locals: App.Locals): number {
return (locals.user as { roleId?: number } | null)?.roleId ?? 4;
}
const ProclamationSchema = z.object({
name: z.string().min(1, 'Required'),
solfeggioFrequencyId: z.coerce.number().int(),
categoryId: z.coerce.number().int(),
groupId: z.coerce.number().int(),
content: z.string().optional().default('')
});
function isSuper(locals: App.Locals): boolean {
return viewerRoleId(locals) <= 1;
return ((locals.user as { roleId?: number } | null)?.roleId ?? 4) <= 1;
}
export const load: PageServerLoad = async ({ locals }) => {
const base = await loadRecords(locals, config);
const [proclamations, frequencies, categories, groups] = await Promise.all([
prisma.proclamation.findMany({
include: { category: true, group: true, solfeggioFrequency: true },
orderBy: { name: 'asc' }
}),
prisma.solfeggioFrequency.findMany({
select: { id: true, name: true, value: true },
orderBy: { name: 'asc' }
}),
prisma.proclamationCategory.findMany({
select: { id: true, name: true },
orderBy: { name: 'asc' }
}),
prisma.proclamationGroup.findMany({
select: { id: true, name: true },
orderBy: { sortOrder: 'asc' }
})
]);
const records = proclamations as unknown as Record<string, unknown>[];
// Strip content from non-Super users
if (!isSuper(locals)) {
for (const record of base.records) {
for (const record of records) {
delete record.content;
}
}
const categories = await prisma.proclamationCategory.findMany({
select: { id: true, name: true, parentId: true },
orderBy: { name: 'asc' }
});
return { ...base, categories };
return { records, frequencies, categories, groups };
};
export const actions = {
upsert: async ({ request, locals }) => {
if (!locals.user) {
return { success: false, error: 'Unauthorized' };
return { success: false, action: 'upsert', error: 'Unauthorized' };
}
if (!isSuper(locals)) {
return { success: false, error: 'Only Super admins can edit proclamations' };
return {
success: false,
action: 'upsert',
error: 'Only Super admins can edit proclamations'
};
}
const formData = await request.formData();
const form = await superValidate(formData, zod4(Schemas.Proclamation));
const form = await superValidate(formData, zod4(ProclamationSchema));
if (!form.valid) {
const { error, fieldErrors } = superFormErrorsToFieldErrors(
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, error, fieldErrors };
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const { parentCategoryId, ...rest } = form.data;
const data: Record<string, unknown> = {
...rest,
categoryId: rest.categoryId ?? parentCategoryId
};
const data: Record<string, unknown> = { ...form.data };
try {
if (rawId) {
const existing = await prisma.proclamation.findUnique({
where: { id: rawId }
});
const existing = await prisma.proclamation.findUnique({ where: { id: rawId } });
if (!existing) {
return { success: false, error: 'Record not found' };
return { success: false, action: 'upsert', error: 'Record not found' };
}
await prisma.proclamation.update({
where: { id: rawId },
data: data as any
});
await prisma.proclamation.update({ where: { id: rawId }, data: data as any });
} else {
await prisma.proclamation.create({ data: data as any });
}
@@ -80,18 +89,41 @@ export const actions = {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
action: 'upsert',
error: 'A record with this name already exists.'
};
}
throw e;
}
return { success: true };
return { success: true, action: 'upsert' };
},
delete: async ({ request, locals }) => {
if (!isSuper(locals)) {
return { success: false, error: 'Only Super admins can delete proclamations' };
return {
success: false,
action: 'delete',
error: 'Only Super admins can delete proclamations'
};
}
return deleteRecord(request, config, locals);
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, action: 'delete', error: 'Missing id' };
try {
await prisma.proclamation.delete({ where: { id: rawId } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return {
success: false,
action: 'delete',
error: 'Cannot delete: this proclamation is assigned to users.'
};
}
throw e;
}
return { success: true, action: 'delete' };
}
};
@@ -1,95 +1,339 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Highlight from '@tiptap/extension-highlight';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import ProclamationForm from './ProclamationForm.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
const config = getModelConfig('proclamations')!;
const viewerRoleId = $derived(data.adminUser?.roleId ?? 4);
const isSuper = $derived(viewerRoleId <= 1);
const canDelete = $derived(viewerRoleId <= 2);
const columns: DataTableColumn[] = [
{ label: 'Name', value: (r) => r.name },
{
label: 'Category',
value: (r) => (r.category as Record<string, unknown> | undefined)?.name ?? ''
},
{ label: 'Group', value: (r) => (r.group as Record<string, unknown> | undefined)?.name ?? '' },
{
label: 'Frequency',
value: (r) => (r.solfeggioFrequency as Record<string, unknown> | undefined)?.name ?? ''
}
];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let name = $state('');
let solfeggioFrequencyId = $state('');
let categoryId = $state('');
let groupId = $state('');
let content = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
let element = $state<HTMLElement>();
let editor: Editor | null = null;
let contentKey = $derived(editingRecord?.id ?? 'new');
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
const record = editingRecord ?? {};
name = String(record.name ?? '');
solfeggioFrequencyId = String(record.solfeggioFrequencyId ?? '');
categoryId = String(record.categoryId ?? '');
groupId = String(record.groupId ?? '');
content = typeof record.content === 'string' ? record.content : '';
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Proclamation updated' : 'Proclamation created';
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
$effect(() => {
if (!isSuper || !modalOpen || !element) return;
const initialContent =
typeof (editingRecord ?? {})['content'] === 'string'
? ((editingRecord ?? {})['content'] as string)
: '';
editor = new Editor({
element,
extensions: [
StarterKit.configure({
bulletList: false,
orderedList: false
}),
TextStyle,
Color,
Highlight.configure({ multicolor: true })
],
content: initialContent,
onUpdate: ({ editor }) => {
content = editor.getHTML();
}
});
const instance = editor;
instance.setEditable(true);
return () => {
instance.destroy();
};
});
function toggleBold() {
editor?.chain().focus().toggleBold().run();
}
function toggleItalic() {
editor?.chain().focus().toggleItalic().run();
}
function toggleHighlight() {
editor?.chain().focus().toggleHighlight().run();
}
function setColor() {
const current = editor?.getAttributes('textStyle').color ?? '#000000';
const colors = ['#000000', '#FF0000', '#0000FF', '#008000', '#FFA500', '#800080', '#808080'];
const idx = colors.indexOf(current);
const next = colors[(idx + 1) % colors.length];
editor?.chain().focus().setColor(next).run();
}
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="Proclamations"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Proclamation"
searchPlaceholder="Search proclamations..."
onRowClick={isSuper ? openEdit : undefined}
onNew={isSuper ? openCreate : undefined}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<ProclamationForm
record={editingRecord ?? {}}
relationOptionsData={data.relationOptionsData}
categories={data.categories}
{fieldErrors}
{formError}
{isSuper}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit Proclamation' : 'Create Proclamation'}
open={modalOpen}
onClose={closeModal}
canDelete={isSuper}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label
for="field-solfeggioFrequencyId"
class="mb-1 block text-sm font-medium text-slate-700">Frequency</label
>
<select
id="field-solfeggioFrequencyId"
name="solfeggioFrequencyId"
required
bind:value={solfeggioFrequencyId}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
>
<option value="">-- Select Frequency --</option>
{#each data.frequencies as freq (String(freq.id))}
<option value={String(freq.id)}>{freq.name}</option>
{/each}
</select>
{#if fieldErrors.solfeggioFrequencyId}
<p class="mt-1 text-sm text-red-600">{fieldErrors.solfeggioFrequencyId}</p>
{/if}
</div>
<div>
<label for="field-categoryId" class="mb-1 block text-sm font-medium text-slate-700"
>Category</label
>
<select
id="field-categoryId"
name="categoryId"
required
bind:value={categoryId}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
>
<option value="">-- Select Category --</option>
{#each data.categories as cat (String(cat.id))}
<option value={String(cat.id)}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.categoryId}
<p class="mt-1 text-sm text-red-600">{fieldErrors.categoryId}</p>
{/if}
</div>
<div>
<label for="field-groupId" class="mb-1 block text-sm font-medium text-slate-700"
>Group</label
>
<select
id="field-groupId"
name="groupId"
required
bind:value={groupId}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
>
<option value="">-- Select Group --</option>
{#each data.groups as group (String(group.id))}
<option value={String(group.id)}>{group.name}</option>
{/each}
</select>
{#if fieldErrors.groupId}
<p class="mt-1 text-sm text-red-600">{fieldErrors.groupId}</p>
{/if}
</div>
<div>
<label for="field-content" class="mb-1 block text-sm font-medium text-slate-700"
>Content</label
>
<div class="rounded-md border border-slate-300 bg-white">
<div class="flex gap-1 border-b border-slate-200 px-2 py-1.5">
<button
type="button"
onclick={toggleBold}
title="Bold"
class="rounded px-2 py-1 text-sm font-bold text-slate-600 transition hover:bg-slate-100"
>B</button
>
<button
type="button"
onclick={toggleItalic}
title="Italic"
class="rounded px-2 py-1 text-sm text-slate-600 italic transition hover:bg-slate-100"
>I</button
>
<button
type="button"
onclick={toggleHighlight}
title="Highlight"
class="rounded px-2 py-1 text-sm text-slate-600 transition hover:bg-slate-100"
>H</button
>
<button
type="button"
onclick={setColor}
title="Cycle text color"
class="rounded px-2 py-1 text-sm text-slate-600 transition hover:bg-slate-100"
>A</button
>
</div>
{#key contentKey}
<div
bind:this={element}
data-testid="tiptap-editor"
role="textbox"
tabindex="0"
contenteditable="true"
class="px-3 py-2 text-sm text-slate-900 focus:outline-none"
></div>
{/key}
<input type="hidden" name="content" value={content} />
</div>
{#if fieldErrors.content}
<p class="mt-1 text-sm text-red-600">{fieldErrors.content}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save Proclamation' : 'Create Proclamation'}
</button>
</div>
</form>
</AdminModal>
{/if}
@@ -1,237 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
import TipTap from '$lib/components/tiptap/TipTap.svelte';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
relationOptionsData = {} as Record<string, { label: string; value: string | number }[]>,
categories = [] as { id: number; name: string; parentId: number | null }[],
isSuper = false,
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
relationOptionsData?: Record<string, { label: string; value: string | number }[]>;
categories?: { id: number; name: string; parentId: number | null }[];
isSuper?: boolean;
action?: string;
submitLabel?: string;
} = $props();
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
let name = $state('');
let solfeggioFrequencyId = $state('');
let subscriptionLevelId = $state('');
let parentCategoryId = $state('');
let categoryId = $state('');
$effect(() => {
name = String(record.name ?? '');
solfeggioFrequencyId = String(record.solfeggioFrequencyId ?? '');
subscriptionLevelId = String(record.subscriptionLevelId ?? '');
parentCategoryId = String(
record.parentCategoryId ??
(record.category as Record<string, unknown> | undefined)?.parentId ??
''
);
categoryId = String(record.categoryId ?? '');
});
let parentOptions = $derived(categories.filter((c) => c.parentId === null));
let childOptions = $derived(
parentCategoryId ? categories.filter((c) => c.parentId === Number(parentCategoryId)) : []
);
function handleParentChange(e: Event) {
const target = e.target as HTMLSelectElement;
parentCategoryId = target.value;
categoryId = '';
}
let contentKey = $derived(record.id ?? 'new');
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Name
</label>
<input
type="text"
id="field-name"
name="name"
bind:value={name}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label
for="field-solfeggioFrequencyId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Frequency
</label>
<select
id="field-solfeggioFrequencyId"
name="solfeggioFrequencyId"
onchange={(e) => (solfeggioFrequencyId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.solfeggioFrequencyId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Frequency --</option>
{#each relationOptionsData.solfeggioFrequencyId ?? [] as opt (String(opt.value))}
<option value={opt.value} selected={String(opt.value) === solfeggioFrequencyId}
>{opt.label}</option
>
{/each}
</select>
{#if fieldErrors.solfeggioFrequencyId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.solfeggioFrequencyId}</p>
{/if}
</div>
<div>
<label
for="field-parentCategoryId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Category
</label>
<select
id="field-parentCategoryId"
name="parentCategoryId"
onchange={handleParentChange}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.parentCategoryId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Category --</option>
{#each parentOptions as cat (String(cat.id))}
<option value={cat.id} selected={String(cat.id) === parentCategoryId}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.parentCategoryId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.parentCategoryId}</p>
{/if}
</div>
<div>
<label
for="field-categoryId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Sub-Category
</label>
<select
id="field-categoryId"
name="categoryId"
onchange={(e) => (categoryId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.categoryId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Sub-Category --</option>
{#each childOptions as cat (String(cat.id))}
<option value={cat.id} selected={String(cat.id) === categoryId}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.categoryId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.categoryId}</p>
{/if}
</div>
<div>
<label
for="field-subscriptionLevelId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Subscription Level
</label>
<select
id="field-subscriptionLevelId"
name="subscriptionLevelId"
onchange={(e) => (subscriptionLevelId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.subscriptionLevelId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Level --</option>
{#each relationOptionsData.subscriptionLevelId ?? [] as opt (String(opt.value))}
<option value={opt.value} selected={String(opt.value) === subscriptionLevelId}
>{opt.label}</option
>
{/each}
</select>
{#if fieldErrors.subscriptionLevelId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.subscriptionLevelId}</p>
{/if}
</div>
{#if isSuper}
<div>
<label
for="field-content"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Content
</label>
<div id="field-content">
{#key record.id}
<TipTap
initialContent={typeof record.content === 'string' ? record.content : ''}
name="content"
/>
{/key}
</div>
{#if fieldErrors.content}
<p class="mt-1 text-xs text-red-600">{fieldErrors.content}</p>
{/if}
</div>
{/if}
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
@@ -1,152 +0,0 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
vi.mock('$app/forms', () => ({
enhance: () => () => {}
}));
vi.mock('$lib/components/tiptap/TipTap.svelte', () => ({
default: ($anchor: Element) => {
const div = document.createElement('div');
div.setAttribute('data-testid', 'tiptap-mock');
$anchor.replaceWith(div);
}
}));
import ProclamationForm from './ProclamationForm.svelte';
const relationOptionsData = {
solfeggioFrequencyId: [
{ label: 'UT - 396', value: 1 },
{ label: 'RE - 417', value: 2 }
],
subscriptionLevelId: [
{ label: 'Free', value: 1 },
{ label: 'Premium', value: 2 }
]
};
const categories = [
{ id: 1, name: 'Healing', parentId: null },
{ id: 2, name: 'Manifestation', parentId: null },
{ id: 3, name: 'Physical Healing', parentId: 1 },
{ id: 4, name: 'Emotional Healing', parentId: 1 }
];
describe('ProclamationForm', () => {
it('renders all basic fields in create mode', async () => {
render(ProclamationForm, {
relationOptionsData,
categories,
isSuper: true
});
await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Frequency$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Category$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Sub-Category$/ }))
.toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Subscription Level$/ }))
.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
it('pre-fills name input when editing an existing record', async () => {
render(ProclamationForm, {
record: {
id: 'proc-1',
name: 'Test Proclamation',
solfeggioFrequencyId: 2,
subscriptionLevelId: 2,
categoryId: 3,
parentCategoryId: 1
},
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await expect
.element(page.getByRole('textbox', { name: 'Name' }))
.toHaveValue('Test Proclamation');
});
it('pre-fills select values when editing an existing record', async () => {
render(ProclamationForm, {
record: {
id: 'proc-1',
name: 'Test Proclamation',
solfeggioFrequencyId: 2,
subscriptionLevelId: 2,
categoryId: 3,
parentCategoryId: 1
},
relationOptionsData,
categories,
isSuper: true
});
const freqEl = (await page
.getByRole('combobox', { name: /^Frequency$/ })
.element()) as HTMLSelectElement;
const levelEl = (await page
.getByRole('combobox', { name: /^Subscription Level$/ })
.element()) as HTMLSelectElement;
expect(freqEl.value).toBe('2');
expect(levelEl.value).toBe('2');
});
it('shows content field when isSuper is true', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test', content: '<p>Secret</p>' },
relationOptionsData,
categories,
isSuper: true
});
await expect.element(page.getByText('Content')).toBeInTheDocument();
await expect.element(page.getByTestId('tiptap-mock')).toBeInTheDocument();
});
it('hides content field when isSuper is false', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test', content: '<p>Secret</p>' },
relationOptionsData,
categories,
isSuper: false
});
await expect.element(page.getByText('Content')).not.toBeInTheDocument();
});
it('disables submit button when editing and not dirty', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test' },
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await expect.element(page.getByRole('button', { name: 'Update' })).toBeDisabled();
});
it('enables submit button when editing and user modifies a field', async () => {
render(ProclamationForm, {
record: { id: 'proc-1', name: 'Test' },
relationOptionsData,
categories,
isSuper: true,
submitLabel: 'Update'
});
await page.getByRole('textbox', { name: 'Name' }).fill('New Name');
await expect.element(page.getByRole('button', { name: 'Update' })).toBeEnabled();
});
});
@@ -1,6 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser, createFormRequest } from '$lib/test-utils';
import { createLocals, mockUser, createFormRequest, mockProclamation } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
@@ -11,29 +10,44 @@ vi.mock('$lib/db', () => ({
update: vi.fn(),
delete: vi.fn()
},
solfeggioFrequency: {
findMany: vi.fn()
},
proclamationCategory: {
findMany: vi.fn()
},
proclamationGroup: {
findMany: vi.fn()
}
}
}));
vi.mock('$lib/svelte-admin/crud', () => ({
loadRecords: vi.fn(),
deleteRecord: vi.fn(),
superFormErrorsToFieldErrors: vi.fn(() => ({ error: '', fieldErrors: {} }))
}));
import { prisma } from '$lib/db';
import { loadRecords, deleteRecord, superFormErrorsToFieldErrors } from '$lib/svelte-admin/crud';
import { load, actions } from './+page.server';
const mockLoadRecords = loadRecords as ReturnType<typeof vi.fn>;
const mockDeleteRecord = deleteRecord as ReturnType<typeof vi.fn>;
const mockSuperFormErrorsToFieldErrors = superFormErrorsToFieldErrors as ReturnType<typeof vi.fn>;
const mockProclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const mockProclamationFindUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
const mockProclamationCreate = prisma.proclamation.create as ReturnType<typeof vi.fn>;
const mockProclamationUpdate = prisma.proclamation.update as ReturnType<typeof vi.fn>;
const mockProclamationDelete = prisma.proclamation.delete as ReturnType<typeof vi.fn>;
const mockFreqFindMany = prisma.solfeggioFrequency.findMany as ReturnType<typeof vi.fn>;
const mockCatFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
const mockFindUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
const mockCreate = prisma.proclamation.create as ReturnType<typeof vi.fn>;
const mockUpdate = prisma.proclamation.update as ReturnType<typeof vi.fn>;
const mockGroupFindMany = prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>;
const frequencies = [{ id: 1, name: 'UT', value: 396 }];
const categories = [{ id: 1, name: 'Healing' }];
const groups = [{ id: 1, name: 'General' }];
function validRequest(overrides: Record<string, string> = {}) {
return createFormRequest({
name: 'Test Proclamation',
solfeggioFrequencyId: '1',
categoryId: '1',
groupId: '1',
content: '<p>Hello</p>',
...overrides
});
}
describe('proclamations admin page server', () => {
beforeEach(() => {
@@ -45,37 +59,42 @@ describe('proclamations admin page server', () => {
{ id: '1', name: 'A', content: '<p>secret</p>' },
{ id: '2', name: 'B', content: '<p>secret2</p>' }
];
const relationOptionsData = {};
const categories = [{ id: 1, name: 'Healing', parentId: null }];
beforeEach(() => {
mockProclamationFindMany.mockResolvedValue(recordsWithContent);
mockFreqFindMany.mockResolvedValue(frequencies);
mockCatFindMany.mockResolvedValue(categories);
mockGroupFindMany.mockResolvedValue(groups);
});
it('strips content for non-Super users', async () => {
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
mockCatFindMany.mockResolvedValue(categories);
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const result = await load({ locals } as any);
expect(mockProclamationFindMany).toHaveBeenCalledWith({
include: { category: true, group: true, solfeggioFrequency: true },
orderBy: { name: 'asc' }
});
expect(result).toEqual({
records: [
{ id: '1', name: 'A' },
{ id: '2', name: 'B' }
],
relationOptionsData,
categories
frequencies,
categories,
groups
});
});
it('keeps content for Super users', async () => {
mockLoadRecords.mockResolvedValue({ records: recordsWithContent, relationOptionsData });
mockCatFindMany.mockResolvedValue(categories);
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await load({ locals } as any);
expect(result).toEqual({
records: recordsWithContent,
relationOptionsData,
categories
frequencies,
categories,
groups
});
});
});
@@ -86,7 +105,7 @@ describe('proclamations admin page server', () => {
const req = createFormRequest({ name: 'test' });
const result = await actions.upsert({ request: req, locals } as any);
expect(result).toEqual({ success: false, error: 'Unauthorized' });
expect(result).toEqual({ success: false, action: 'upsert', error: 'Unauthorized' });
});
it('rejects non-Super users', async () => {
@@ -96,40 +115,133 @@ describe('proclamations admin page server', () => {
const result = await actions.upsert({ request: req, locals } as any);
expect(result).toEqual({
success: false,
action: 'upsert',
error: 'Only Super admins can edit proclamations'
});
});
it('returns field errors for invalid input', async () => {
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const req = validRequest({ solfeggioFrequencyId: 'not-a-number' });
const result = await actions.upsert({ request: req, locals } as any);
expect(result.success).toBe(false);
expect(
(result as { fieldErrors: Record<string, string> }).fieldErrors.solfeggioFrequencyId
).toBeDefined();
});
it('creates a proclamation for a Super user', async () => {
mockProclamationCreate.mockResolvedValue({ id: 'p1' });
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await actions.upsert({ request: validRequest(), locals } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProclamationCreate).toHaveBeenCalledWith({
data: {
name: 'Test Proclamation',
solfeggioFrequencyId: 1,
categoryId: 1,
groupId: 1,
content: '<p>Hello</p>'
}
});
});
it('updates an existing proclamation for a Super user', async () => {
mockProclamationFindUnique.mockResolvedValue(mockProclamation());
mockProclamationUpdate.mockResolvedValue({});
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await actions.upsert({
request: validRequest({ id: 'proc-1', name: 'Renamed' }),
locals
} as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProclamationUpdate).toHaveBeenCalledWith({
where: { id: 'proc-1' },
data: {
name: 'Renamed',
solfeggioFrequencyId: 1,
categoryId: 1,
groupId: 1,
content: '<p>Hello</p>'
}
});
});
it('returns record-not-found for a missing id', async () => {
mockProclamationFindUnique.mockResolvedValue(null);
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await actions.upsert({
request: validRequest({ id: 'missing' }),
locals
} as any);
expect(result).toEqual({ success: false, action: 'upsert', error: 'Record not found' });
});
it('returns a friendly error on duplicate name (P2002)', async () => {
mockProclamationCreate.mockRejectedValue({ code: 'P2002' });
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const result = await actions.upsert({ request: validRequest(), locals } as any);
expect(result).toEqual({
success: false,
action: 'upsert',
error: 'A record with this name already exists.'
});
});
});
describe('delete action', () => {
it('rejects non-Super users', async () => {
const locals = createLocals(mockUser({ roleId: 4 }) as any);
const req = new Request('http://localhost', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id: '1' })
});
const req = createFormRequest({ id: '1' });
const result = await actions.delete({ request: req, locals } as any);
expect(result).toEqual({
success: false,
action: 'delete',
error: 'Only Super admins can delete proclamations'
});
});
it('delegates to deleteRecord for Super users', async () => {
mockDeleteRecord.mockResolvedValue({ success: true });
it('returns an error when id is missing', async () => {
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const req = new Request('http://localhost', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id: '1' })
});
const req = createFormRequest({});
const result = await actions.delete({ request: req, locals } as any);
expect(mockDeleteRecord).toHaveBeenCalled();
expect(result).toEqual({ success: true });
expect(result).toEqual({ success: false, action: 'delete', error: 'Missing id' });
});
it('deletes a proclamation for a Super user', async () => {
mockProclamationDelete.mockResolvedValue({});
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const req = createFormRequest({ id: 'proc-1' });
const result = await actions.delete({ request: req, locals } as any);
expect(result).toEqual({ success: true, action: 'delete' });
expect(mockProclamationDelete).toHaveBeenCalledWith({ where: { id: 'proc-1' } });
});
it('returns a friendly error when the proclamation is assigned to users (P2003)', async () => {
mockProclamationDelete.mockRejectedValue({ code: 'P2003' });
const locals = createLocals(mockUser({ roleId: 1 }) as any);
const req = createFormRequest({ id: 'proc-1' });
const result = await actions.delete({ request: req, locals } as any);
expect(result).toEqual({
success: false,
action: 'delete',
error: 'Cannot delete: this proclamation is assigned to users.'
});
});
});
});
@@ -0,0 +1,168 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import type { PageProps } from './$types';
vi.mock('$app/forms', () => ({
enhance: () => () => {}
}));
import Page from './+page.svelte';
const frequencies = [
{ id: 1, name: 'UT', value: 396 },
{ id: 2, name: 'RE', value: 417 }
];
const categories = [
{ id: 1, name: 'Healing' },
{ id: 2, name: 'Manifestation' }
];
const groups = [
{ id: 1, name: 'General' },
{ id: 2, name: 'Specific' }
];
const superAdmin = {
id: 'admin-1',
name: 'Super Admin',
roleId: 1,
roleName: 'SUPER'
};
function makeData(overrides: Partial<PageProps['data']> = {}): PageProps['data'] {
return {
records: [],
frequencies,
categories,
groups,
adminUser: superAdmin,
userRoleId: 1,
...overrides
};
}
function mountPage(dataOverrides: Partial<PageProps['data']> = {}) {
return render(Page, { params: {}, data: makeData(dataOverrides), form: null });
}
const editRecord = {
id: 'proc-1',
name: 'Test Proclamation',
content: '<p>Secret</p>',
solfeggioFrequencyId: 2,
categoryId: 1,
groupId: 2,
category: { name: 'Healing' },
group: { name: 'Specific' },
solfeggioFrequency: { name: 'RE' }
};
async function clickButton(name: string) {
const loc = page.getByRole('button', { name });
const el = (await loc.element()) as HTMLElement;
el.click();
}
async function clickCell(name: string) {
const loc = page.getByRole('cell', { name });
const el = (await loc.element()) as HTMLElement;
el.click();
}
async function openCreateModal() {
await clickButton('New Proclamation');
}
async function openEditModal() {
await clickCell('Test Proclamation');
}
describe('proclamations admin page (super user)', () => {
it('renders all basic fields in create mode', async () => {
mountPage();
await openCreateModal();
await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Frequency$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Category$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Group$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Create Proclamation' }))
.toBeInTheDocument();
});
it('pre-fills name input when editing an existing record', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
await expect
.element(page.getByRole('textbox', { name: 'Name' }))
.toHaveValue('Test Proclamation');
});
it('pre-fills select values when editing an existing record', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
const freqEl = (await page
.getByRole('combobox', { name: /^Frequency$/ })
.element()) as HTMLSelectElement;
const categoryEl = (await page
.getByRole('combobox', { name: /^Category$/ })
.element()) as HTMLSelectElement;
const groupEl = (await page
.getByRole('combobox', { name: /^Group$/ })
.element()) as HTMLSelectElement;
expect(freqEl.value).toBe('2');
expect(categoryEl.value).toBe('1');
expect(groupEl.value).toBe('2');
});
it('keeps group selection independent of category selection (movable filter)', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
const categoryEl = (await page
.getByRole('combobox', { name: /^Category$/ })
.element()) as HTMLSelectElement;
const groupEl = (await page
.getByRole('combobox', { name: /^Group$/ })
.element()) as HTMLSelectElement;
categoryEl.value = '2';
categoryEl.dispatchEvent(new Event('change', { bubbles: true }));
expect(categoryEl.value).toBe('2');
expect(groupEl.value).toBe('2');
});
it('shows content editor when isSuper is true', async () => {
mountPage({ records: [editRecord] });
await openEditModal();
await expect.element(page.getByText('Content')).toBeInTheDocument();
await expect.element(page.getByTestId('tiptap-editor')).toBeInTheDocument();
});
});
describe('proclamations admin page (non-super user)', () => {
it('hides create/edit entry points and content field from non-super users', async () => {
mountPage({
records: [editRecord],
adminUser: { ...superAdmin, id: 'admin-2', roleId: 2, roleName: 'ADMIN' }
});
await expect
.element(page.getByRole('button', { name: 'New Proclamation' }))
.not.toBeInTheDocument();
await expect.element(page.getByText('Content')).not.toBeInTheDocument();
});
});
@@ -1,49 +1,146 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import {
loadRecords,
upsertRecord,
deleteRecord,
superFormErrorsToFieldErrors
} from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import * as Schemas from '$lib/svelte-admin/schemas';
import { z } from 'zod';
const config = getModelConfig('profiles')!;
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
const email = z.string().email('Invalid email address');
const phone = z
.string()
.min(10, 'Phone number must be at least 10 digits')
.regex(/^[\d\-\+\(\) ]+$/, 'Invalid phone number format');
const name = z.string().min(1, 'Required').max(100, 'Too long');
const ProfileSchema = z.object({
email,
firstName: name,
lastName: name,
phoneNumber: phone
});
const AddressSchema = z.object({
profileId: z.string().min(1),
street: z.string().min(1, 'Street is required'),
city: z.string().min(1, 'City is required'),
state: z.string().min(1, 'State is required'),
postalCode: z.string().min(1, 'Postal code is required'),
country: z.string().min(1, 'Country is required'),
type: z.enum(['Mailing', 'Billing', 'Shipping']).default('Mailing'),
isDefaultMailing: z.coerce.boolean().default(false),
isDefaultBilling: z.coerce.boolean().default(false),
isDefaultShipping: z.coerce.boolean().default(false)
});
export const load: PageServerLoad = async () => {
const records = await prisma.profile.findMany({
include: { user: true, addresses: true },
orderBy: { id: 'asc' }
});
return {
records: records as unknown as Record<string, unknown>[]
};
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
},
upsertAddress: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const form = await superValidate(request, zod4(Schemas.Address));
upsert: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(ProfileSchema));
if (!form.valid) {
const { error: addrError, fieldErrors } = superFormErrorsToFieldErrors(
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, addressError: addrError, addressFieldErrors: fieldErrors };
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const data = {
email: form.data.email,
firstName: form.data.firstName,
lastName: form.data.lastName,
phoneNumber: form.data.phoneNumber
};
if (rawId) {
const existing = await prisma.profile.findUnique({
where: { id: rawId },
include: { user: true }
});
if (!existing) {
return { success: false, action: 'upsert', error: 'Record not found' };
}
const updateData: Record<string, unknown> = { ...data };
if (existing.userId) {
if (existing.user?.email !== data.email) {
const conflict = await prisma.user.findFirst({
where: { email: data.email, id: { not: existing.userId } }
});
if (conflict) {
return {
success: false,
action: 'upsert',
error: 'That email already belongs to another user.'
};
}
await prisma.user.update({ where: { id: existing.userId }, data: { email: data.email } });
}
} else {
const match = await prisma.user.findUnique({ where: { email: data.email } });
if (match) updateData.userId = match.id;
}
await prisma.profile.update({ where: { id: rawId }, data: updateData });
return { success: true, action: 'upsert' };
}
try {
const user = await prisma.user.findUnique({ where: { email: data.email } });
await prisma.profile.create({ data: { ...data, ...(user ? { userId: user.id } : {}) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
action: 'upsert',
error: 'A profile for this email already exists.'
};
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
await prisma.profile.delete({ where: { id: rawId } });
return { success: true, action: 'delete' };
},
upsertAddress: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(AddressSchema));
if (!form.valid) {
const { error: addressError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return {
success: false,
action: 'upsertAddress',
addressError,
addressFieldErrors: fieldErrors
};
}
const { profileId, isDefaultMailing, isDefaultBilling, isDefaultShipping, ...addressData } =
form.data;
if (!profileId) {
return { success: false, addressError: 'Missing profile ID' };
return { success: false, action: 'upsertAddress', addressError: 'Missing profile ID' };
}
// Unique-default enforcement: clear defaults of same type before setting
if (isDefaultMailing) {
await prisma.address.updateMany({
where: { profileId, isDefaultMailing: true },
@@ -72,32 +169,6 @@ export const actions = {
};
const created = await prisma.address.create({ data: payload });
return { success: true, address: created };
},
updateUser: async ({ request, locals }) => {
if (!locals.user) throw error(401, 'Unauthorized');
const form = await superValidate(request, zod4(Schemas.UserUpdate));
if (!form.valid) {
const { error: userErr, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, userError: userErr, userFieldErrors: fieldErrors };
}
const { id, email, name } = form.data;
if (!id) {
return { success: false, userError: 'Missing user ID' };
}
const data: Record<string, string> = {};
if (email) data.email = email;
if (name) data.name = name;
if (Object.keys(data).length === 0) {
return { success: false, userError: 'No fields to update' };
}
await prisma.user.update({ where: { id }, data });
return { success: true };
return { success: true, action: 'upsertAddress', address: created };
}
};
@@ -1,487 +1,59 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { enhance } from '$app/forms';
import { getModelConfig } from '$lib/svelte-admin/registry';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import CreateProfileModal from './CreateProfileModal.svelte';
import EditProfileModal from './EditProfileModal.svelte';
let { data, form }: PageProps = $props();
const config = getModelConfig('profiles')!;
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let formError = $state('');
let addressFormOpen = $state(false);
let userFormOpen = $state(false);
let addressError = $state('');
let userError = $state('');
const columns: DataTableColumn[] = [
{ label: 'First Name', value: (r) => r.firstName },
{ label: 'Last Name', value: (r) => r.lastName },
{
label: 'Email',
value: (r) => (r.user as Record<string, unknown> | undefined)?.email ?? r.email ?? ''
},
{ label: 'Phone', value: (r) => r.phoneNumber },
{ label: 'User', value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? '' }
];
let addressFields = $state<Record<string, string>>({
street: '',
city: '',
state: '',
postalCode: '',
country: '',
isDefaultMailing: 'false',
isDefaultBilling: 'false',
isDefaultShipping: 'false'
});
let userEmail = $state('');
let userName = $state('');
$effect(() => {
if (form) {
if ((form as any).addressError) {
addressError = (form as any).addressError;
} else if ((form as any).userError) {
userError = (form as any).userError;
} else if (form.success) {
toastType = 'success';
if ((form as any).address) {
toastMessage = 'Address saved';
const addr = (form as any).address as Record<string, unknown>;
if (editingRecord) {
const addrs = (editingRecord.addresses as Record<string, unknown>[]) ?? [];
editingRecord = {
...editingRecord,
addresses: [...addrs, addr]
};
}
addressFormOpen = false;
addressError = '';
} else {
toastMessage = 'Saved successfully';
closeModal();
}
} else if (!(form as any).addressError && !(form as any).userError) {
formError = (form as ActionResult).error ?? '';
fieldErrors = (form as ActionResult).fieldErrors ?? {};
}
}
});
let createOpen = $state(false);
let editRecord = $state<Record<string, unknown> | null>(null);
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
addressFormOpen = false;
userFormOpen = false;
modalOpen = true;
createOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
addressFormOpen = false;
userFormOpen = false;
addressError = '';
userError = '';
userEmail = ((record.user as Record<string, unknown>)?.email as string) ?? '';
userName = ((record.user as Record<string, unknown>)?.name as string) ?? '';
modalOpen = true;
editRecord = record;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
addressFormOpen = false;
userFormOpen = false;
addressError = '';
userError = '';
function closeCreate() {
createOpen = false;
}
function toggleAddressForm() {
addressFormOpen = !addressFormOpen;
addressError = '';
}
function toggleUserForm() {
userFormOpen = !userFormOpen;
userError = '';
function closeEdit() {
editRecord = null;
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="User Profiles"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Profile"
searchPlaceholder="Search profiles..."
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<form method="POST" action="?/upsert" use:enhance>
{#if editingRecord?.id}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-userId"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
User
</label>
<select
id="field-userId"
name="userId"
value={String(editingRecord?.userId ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.userId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select User --</option>
{#each data.relationOptionsData.userId ?? [] as opt (String(opt.value))}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if fieldErrors.userId}
<p class="mt-1 text-xs text-red-600">{fieldErrors.userId}</p>
{/if}
</div>
<div>
<label
for="field-firstName"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
First Name
</label>
<input
type="text"
id="field-firstName"
name="firstName"
value={String(editingRecord?.firstName ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.firstName
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.firstName}
<p class="mt-1 text-xs text-red-600">{fieldErrors.firstName}</p>
{/if}
</div>
<div>
<label
for="field-lastName"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Last Name
</label>
<input
type="text"
id="field-lastName"
name="lastName"
value={String(editingRecord?.lastName ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.lastName
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.lastName}
<p class="mt-1 text-xs text-red-600">{fieldErrors.lastName}</p>
{/if}
</div>
<div>
<label
for="field-phoneNumber"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Phone Number
</label>
<input
type="text"
id="field-phoneNumber"
name="phoneNumber"
value={String(editingRecord?.phoneNumber ?? '')}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.phoneNumber
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.phoneNumber}
<p class="mt-1 text-xs text-red-600">{fieldErrors.phoneNumber}</p>
{/if}
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{editingRecord ? 'Update' : 'Create'}
</button>
</div>
</div>
</form>
<CreateProfileModal open={createOpen} onClose={closeCreate} {form} />
{#if editingRecord}
{@const user = editingRecord.user as Record<string, unknown> | undefined}
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">User</h3>
<button
type="button"
onclick={toggleUserForm}
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{userFormOpen ? 'Cancel' : 'Edit User'}
</button>
</div>
<p class="mb-1 text-sm text-gray-600 dark:text-gray-400">
{(user?.name as string) ?? ''} ({(user?.email as string) ?? ''})
</p>
{#if userFormOpen}
<form
method="POST"
action="?/updateUser"
use:enhance
class="mt-3 rounded border border-gray-200 p-3 dark:border-gray-700"
>
<input type="hidden" name="id" value={String(user?.id ?? '')} />
{#if userError}
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
{userError}
</div>
{/if}
<div class="flex flex-col gap-2">
<div>
<label
for="user-name"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Name
</label>
<input
type="text"
id="user-name"
name="name"
value={userName}
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label
for="user-email"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Email
</label>
<input
type="email"
id="user-email"
name="email"
value={userEmail}
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div class="flex justify-end">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-3 py-1 text-xs font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Save User
</button>
</div>
</div>
</form>
{/if}
</div>
{/if}
{#if editingRecord}
{@const addresses = (editingRecord.addresses as Record<string, unknown>[]) ?? []}
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<div class="mb-2 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Addresses</h3>
<button
type="button"
onclick={toggleAddressForm}
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
>
{addressFormOpen ? 'Cancel' : 'Add Address +'}
</button>
</div>
{#if addressFormOpen}
<form
method="POST"
action="?/upsertAddress"
use:enhance
class="mb-3 rounded border border-gray-200 p-3 dark:border-gray-700"
>
<input type="hidden" name="profileId" value={String(editingRecord.id)} />
{#if addressError}
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
{addressError}
</div>
{/if}
<div class="grid grid-cols-2 gap-2">
<div>
<label
for="addr-street"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Street
</label>
<input
type="text"
id="addr-street"
name="street"
required
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label
for="addr-city"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
City
</label>
<input
type="text"
id="addr-city"
name="city"
required
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label
for="addr-state"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
State
</label>
<input
type="text"
id="addr-state"
name="state"
required
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label
for="addr-postal"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Postal Code
</label>
<input
type="text"
id="addr-postal"
name="postalCode"
required
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
<div>
<label
for="addr-country"
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
>
Country
</label>
<input
type="text"
id="addr-country"
name="country"
required
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/>
</div>
</div>
<div class="mt-2 flex flex-wrap gap-4">
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input type="checkbox" name="isDefaultMailing" class="h-3.5 w-3.5" />
Default Mailing
</label>
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input type="checkbox" name="isDefaultBilling" class="h-3.5 w-3.5" />
Default Billing
</label>
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
<input type="checkbox" name="isDefaultShipping" class="h-3.5 w-3.5" />
Default Shipping
</label>
</div>
<div class="mt-2 flex justify-end">
<button
type="submit"
class="border border-gray-300 bg-gray-100 px-3 py-1 text-xs font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Save Address
</button>
</div>
</form>
{/if}
{#if addresses.length > 0}
<table class="w-full text-sm">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Street</th
>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">City</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">State</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Zip</th>
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase"
>Country</th
>
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">M</th>
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">B</th>
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">S</th>
</tr>
</thead>
<tbody>
{#each addresses as addr}
<tr class="border-b border-gray-100 dark:border-gray-800">
<td class="px-2 py-1">{addr.street as string}</td>
<td class="px-2 py-1">{addr.city as string}</td>
<td class="px-2 py-1">{addr.state as string}</td>
<td class="px-2 py-1">{addr.postalCode as string}</td>
<td class="px-2 py-1">{addr.country as string}</td>
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultMailing ? '✓' : ''}</td>
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultBilling ? '✓' : ''}</td>
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultShipping ? '✓' : ''}</td>
</tr>
{/each}
</tbody>
</table>
{:else}
<p class="text-sm text-gray-500">No addresses on file.</p>
{/if}
</div>
{/if}
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{#if editRecord}
{#key editRecord.id}
<EditProfileModal record={editRecord} open={true} onClose={closeEdit} {canDelete} {form} />
{/key}
{/if}
@@ -0,0 +1,86 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import ProfileFields from './ProfileFields.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let {
open = false,
onClose,
form
}: {
open?: boolean;
onClose?: () => void;
form?: ActionResult;
} = $props();
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
if (open) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = 'Profile created';
setTimeout(() => onClose?.(), 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
</script>
<AdminModal title="Create Profile" {open} {onClose}>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
<form method="POST" action="?/upsert" use:enhance>
<ProfileFields record={null} {fieldErrors} />
<div class="mt-6 flex justify-end gap-3">
<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"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Create Profile
</button>
</div>
</form>
</AdminModal>
@@ -0,0 +1,305 @@
<script lang="ts">
import { enhance } from '$app/forms';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import ProfileFields from './ProfileFields.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
addressError?: string;
address?: Record<string, unknown>;
} | null;
let {
record,
open = false,
onClose,
canDelete = false,
form
}: {
record: Record<string, unknown>;
open?: boolean;
onClose?: () => void;
canDelete?: boolean;
form?: ActionResult;
} = $props();
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let addressError = $state('');
let successMessage = $state('');
let addressFormOpen = $state(false);
let addresses = $state<Record<string, unknown>[]>([]);
let addrStreet = $state('');
let addrCity = $state('');
let addrState = $state('');
let addrPostal = $state('');
let addrCountry = $state('');
let addrMailing = $state(false);
let addrBilling = $state(false);
let addrShipping = $state(false);
let lastRecordId = $state<string | null>(null);
let lastForm = $state<ActionResult>(null);
function resetAddressFields() {
addrStreet = '';
addrCity = '';
addrState = '';
addrPostal = '';
addrCountry = '';
addrMailing = false;
addrBilling = false;
addrShipping = false;
}
function seedRecord() {
addresses = (record.addresses as Record<string, unknown>[]) ?? [];
addressFormOpen = false;
addressError = '';
formError = '';
fieldErrors = {};
successMessage = '';
resetAddressFields();
}
$effect(() => {
if (open) {
seedRecord();
}
});
$effect(() => {
const id = String(record.id ?? '');
if (id === lastRecordId) return;
lastRecordId = id;
seedRecord();
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
switch (form.action) {
case 'upsertAddress':
if (form.success && form.address) {
addresses = [...addresses, form.address];
addressFormOpen = false;
addressError = '';
successMessage = 'Address saved';
resetAddressFields();
} else {
successMessage = '';
addressError = form.addressError ?? '';
}
break;
case 'upsert':
default:
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = 'Profile updated';
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
break;
}
});
function toggleAddressForm() {
addressFormOpen = !addressFormOpen;
addressError = '';
if (addressFormOpen) resetAddressFields();
}
</script>
<AdminModal title="Edit Profile" {open} {onClose} {canDelete} recordId={String(record.id ?? '')}>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
<form method="POST" action="?/upsert" use:enhance>
<input type="hidden" name="id" value={String(record.id ?? '')} />
<ProfileFields {record} {fieldErrors} />
<div class="mt-6 flex justify-end gap-3">
<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"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Save Profile
</button>
</div>
</form>
<div class="mt-8 border-t border-slate-200 pt-6">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900">Addresses</h3>
<button
type="button"
onclick={toggleAddressForm}
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
>
{addressFormOpen ? 'Cancel' : 'Add Address +'}
</button>
</div>
{#if addressFormOpen}
<form method="POST" action="?/upsertAddress" use:enhance class="mt-4 flex flex-col gap-4">
<input type="hidden" name="profileId" value={String(record.id ?? '')} />
{#if addressError}
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{addressError}
</div>
{/if}
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label for="addr-street" class="mb-1 block text-sm font-medium text-slate-700"
>Street</label
>
<input
id="addr-street"
type="text"
name="street"
required
bind:value={addrStreet}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-city" class="mb-1 block text-sm font-medium text-slate-700">City</label
>
<input
id="addr-city"
type="text"
name="city"
required
bind:value={addrCity}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-state" class="mb-1 block text-sm font-medium text-slate-700"
>State</label
>
<input
id="addr-state"
type="text"
name="state"
required
bind:value={addrState}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-postal" class="mb-1 block text-sm font-medium text-slate-700"
>Postal Code</label
>
<input
id="addr-postal"
type="text"
name="postalCode"
required
bind:value={addrPostal}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
<div>
<label for="addr-country" class="mb-1 block text-sm font-medium text-slate-700"
>Country</label
>
<input
id="addr-country"
type="text"
name="country"
required
bind:value={addrCountry}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
</div>
</div>
<div class="flex flex-wrap gap-4 text-sm text-slate-700">
<label class="flex items-center gap-2">
<input type="checkbox" name="isDefaultMailing" bind:checked={addrMailing} />
Default Mailing
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="isDefaultBilling" bind:checked={addrBilling} />
Default Billing
</label>
<label class="flex items-center gap-2">
<input type="checkbox" name="isDefaultShipping" bind:checked={addrShipping} />
Default Shipping
</label>
</div>
<div class="flex justify-end">
<button
type="submit"
class="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700"
>
Save Address
</button>
</div>
</form>
{/if}
{#if addresses.length > 0}
<div class="mt-4 overflow-x-auto rounded-md border border-slate-200">
<table class="w-full min-w-max text-left text-sm text-slate-700">
<thead>
<tr
class="border-b border-slate-200 bg-slate-100 text-xs font-semibold tracking-wide text-slate-500"
>
<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">State</th>
<th scope="col" class="px-3 py-2">Zip</th>
<th scope="col" class="px-3 py-2">Country</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">S</th>
</tr>
</thead>
<tbody>
{#each addresses as addr, i (String(addr.id ?? i))}
<tr class="bg-white">
<td class="px-3 py-2 whitespace-nowrap">{addr.street as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.city as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.state as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.postalCode as string}</td>
<td class="px-3 py-2 whitespace-nowrap">{addr.country as string}</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.isDefaultShipping ? '✓' : ''}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="mt-2 text-sm text-slate-600">No addresses on file.</p>
{/if}
</div>
</AdminModal>
@@ -0,0 +1,92 @@
<script lang="ts">
let {
record = null,
fieldErrors = {} as Record<string, string>
}: {
record?: Record<string, unknown> | null;
fieldErrors?: Record<string, string>;
} = $props();
let email = $state('');
let firstName = $state('');
let lastName = $state('');
let phoneNumber = $state('');
$effect(() => {
const rec = record ?? {};
const user = rec.user as Record<string, unknown> | undefined;
email = String((user?.email as string) ?? rec.email ?? '');
firstName = String(rec.firstName ?? '');
lastName = String(rec.lastName ?? '');
phoneNumber = String(rec.phoneNumber ?? '');
});
</script>
<div class="flex flex-col gap-4">
<div>
<label for="field-email" class="mb-1 block text-sm font-medium text-slate-700">Email</label>
<input
id="field-email"
type="email"
name="email"
required
bind:value={email}
placeholder="user@example.com"
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.email}
<p class="mt-1 text-sm text-red-600">{fieldErrors.email}</p>
{/if}
</div>
<div>
<label for="field-firstName" class="mb-1 block text-sm font-medium text-slate-700"
>First Name</label
>
<input
id="field-firstName"
type="text"
name="firstName"
required
bind:value={firstName}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.firstName}
<p class="mt-1 text-sm text-red-600">{fieldErrors.firstName}</p>
{/if}
</div>
<div>
<label for="field-lastName" class="mb-1 block text-sm font-medium text-slate-700"
>Last Name</label
>
<input
id="field-lastName"
type="text"
name="lastName"
required
bind:value={lastName}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.lastName}
<p class="mt-1 text-sm text-red-600">{fieldErrors.lastName}</p>
{/if}
</div>
<div>
<label for="field-phoneNumber" class="mb-1 block text-sm font-medium text-slate-700">
Phone Number
</label>
<input
id="field-phoneNumber"
type="text"
name="phoneNumber"
required
bind:value={phoneNumber}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.phoneNumber}
<p class="mt-1 text-sm text-red-600">{fieldErrors.phoneNumber}</p>
{/if}
</div>
</div>
@@ -0,0 +1,233 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createLocals, createFormRequest } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
profile: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
},
user: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { load, actions } from './+page.server';
const mockProfileFindMany = prisma.profile.findMany as ReturnType<typeof vi.fn>;
const mockProfileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const mockProfileCreate = prisma.profile.create as ReturnType<typeof vi.fn>;
const mockProfileUpdate = prisma.profile.update as ReturnType<typeof vi.fn>;
const mockProfileDelete = prisma.profile.delete as ReturnType<typeof vi.fn>;
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const mockUserFindFirst = prisma.user.findFirst as ReturnType<typeof vi.fn>;
const mockUserUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
const validFields = {
email: 'jane@example.com',
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '555-010-1234'
};
function makeProfileRequest(overrides: Record<string, string> = {}) {
return createFormRequest({ ...validFields, ...overrides });
}
describe('profiles admin page server', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('load', () => {
it('returns profiles with user and addresses', async () => {
const records = [
{
id: 'p1',
email: null,
user: { email: 'jane@example.com', name: 'Jane Doe' },
addresses: []
}
];
mockProfileFindMany.mockResolvedValue(records);
const result = await load({} as any);
expect(mockProfileFindMany).toHaveBeenCalledWith({
include: { user: true, addresses: true },
orderBy: { id: 'asc' }
});
expect(result).toEqual({ records });
});
});
describe('upsert - create', () => {
it('links the profile to an existing user with the same email', async () => {
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: validFields.email });
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { email: validFields.email } });
expect(mockProfileCreate).toHaveBeenCalledWith({
data: { ...validFields, userId: 'user-1' }
});
});
it('stores the email on the profile when no user matches', async () => {
mockUserFindUnique.mockResolvedValue(null);
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProfileCreate).toHaveBeenCalledWith({ data: validFields });
});
it('returns a friendly error when the email already exists (P2002)', async () => {
mockUserFindUnique.mockResolvedValue(null);
mockProfileCreate.mockRejectedValue({ code: 'P2002' });
const result = await actions.upsert({ request: makeProfileRequest() } as any);
expect(result).toEqual({
success: false,
action: 'upsert',
error: 'A profile for this email already exists.'
});
});
});
describe('upsert - update', () => {
it('syncs the linked user email when it changes', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: 'user-1',
email: 'jane@example.com',
user: { email: 'old@example.com' }
});
mockUserFindFirst.mockResolvedValue(null);
mockUserUpdate.mockResolvedValue({});
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ email: 'new@example.com', id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserUpdate).toHaveBeenCalledWith({
where: { id: 'user-1' },
data: { email: 'new@example.com' }
});
expect(mockProfileUpdate).toHaveBeenCalledWith({
where: { id: 'p1' },
data: { ...validFields, email: 'new@example.com' }
});
});
it('does not touch the user when the email is unchanged', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: 'user-1',
email: 'jane@example.com',
user: { email: 'jane@example.com' }
});
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockUserFindFirst).not.toHaveBeenCalled();
expect(mockUserUpdate).not.toHaveBeenCalled();
});
it('rejects a change to an email owned by another user', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: 'user-1',
email: 'jane@example.com',
user: { email: 'old@example.com' }
});
mockUserFindFirst.mockResolvedValue({ id: 'user-2' });
const request = makeProfileRequest({ email: 'taken@example.com', id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({
success: false,
action: 'upsert',
error: 'That email already belongs to another user.'
});
expect(mockUserUpdate).not.toHaveBeenCalled();
expect(mockProfileUpdate).not.toHaveBeenCalled();
});
it('links the unlinked profile when a user matches the new email', async () => {
mockProfileFindUnique.mockResolvedValue({
id: 'p1',
userId: null,
email: 'jane@example.com',
user: null
});
mockUserFindUnique.mockResolvedValue({ id: 'user-9' });
mockProfileUpdate.mockResolvedValue({});
const request = makeProfileRequest({ id: 'p1' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: true, action: 'upsert' });
expect(mockProfileUpdate).toHaveBeenCalledWith({
where: { id: 'p1' },
data: { ...validFields, userId: 'user-9' }
});
});
});
describe('upsert - validation', () => {
it('returns field errors for invalid input', async () => {
const request = makeProfileRequest({ email: 'not-an-email' });
const result = await actions.upsert({ request } as any);
expect(result.success).toBe(false);
expect(result.action).toBe('upsert');
expect((result as { fieldErrors: Record<string, string> }).fieldErrors.email).toBeDefined();
});
it('returns a record-not-found error for a missing profile', async () => {
mockProfileFindUnique.mockResolvedValue(null);
const request = makeProfileRequest({ id: 'missing' });
const result = await actions.upsert({ request } as any);
expect(result).toEqual({ success: false, action: 'upsert', error: 'Record not found' });
});
});
describe('delete', () => {
it('deletes the profile by id', async () => {
mockProfileDelete.mockResolvedValue({});
const locals = createLocals();
const req = createFormRequest({ id: 'p1' });
const result = await actions.delete({ request: req, locals } as any);
expect(result).toEqual({ success: true, action: 'delete' });
expect(mockProfileDelete).toHaveBeenCalledWith({ where: { id: 'p1' } });
});
it('returns an error when id is missing', async () => {
const locals = createLocals();
const result = await actions.delete({ request: createFormRequest({}), locals } as any);
expect(result).toEqual({ success: false, error: 'Missing id' });
});
});
});
@@ -1,18 +1,70 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const config = getModelConfig('solfeggio-frequencies')!;
const SolfeggioSchema = z.object({
name: z.string().min(1, 'Required').max(100, 'Too long'),
value: z.coerce.number().int().min(1, 'Value must be a positive integer')
});
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
export const load: PageServerLoad = async () => {
const records = await prisma.solfeggioFrequency.findMany({ orderBy: { id: 'asc' } });
return { records: records as unknown as Record<string, unknown>[] };
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
upsert: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(SolfeggioSchema));
if (!form.valid) {
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const data = { name: form.data.name, value: form.data.value };
try {
if (rawId) {
await prisma.solfeggioFrequency.update({ where: { id: Number(rawId) }, data });
} else {
await prisma.solfeggioFrequency.create({ data });
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return {
success: false,
action: 'upsert',
error: 'A frequency with that name or value already exists.'
};
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
try {
await prisma.solfeggioFrequency.delete({ where: { id: Number(rawId) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return {
success: false,
error: 'Cannot delete: this frequency is used by proclamations.'
};
}
throw e;
}
return { success: true, action: 'delete' };
}
};
@@ -1,90 +1,167 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import FreqForm from './FreqForm.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const config = getModelConfig('solfeggio-frequencies')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const columns: DataTableColumn[] = [
{ label: 'Name', value: (r) => r.name },
{ label: 'Value', value: (r) => r.value }
];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let name = $state('');
let value = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
value = String(rec.value ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Frequency updated' : 'Frequency created';
setTimeout(() => {
modalOpen = false;
successMessage = '';
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="Solfeggio Frequencies"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Frequency"
searchPlaceholder="Search frequencies..."
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<FreqForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit Frequency' : 'Create Frequency'}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label for="field-value" class="mb-1 block text-sm font-medium text-slate-700">Value</label>
<input
id="field-value"
type="number"
name="value"
required
min="1"
step="1"
bind:value
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"
/>
{#if fieldErrors.value}
<p class="mt-1 text-sm text-red-600">{fieldErrors.value}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save Frequency' : 'Create Frequency'}
</button>
</div>
</form>
</AdminModal>
{/if}
@@ -1,96 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
action?: string;
submitLabel?: string;
} = $props();
let name = $state(String(record.name ?? ''));
let value = $state(String(record.value ?? ''));
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Name
</label>
<input
type="text"
id="field-name"
name="name"
bind:value={name}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label
for="field-value"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Value
</label>
<input
type="number"
id="field-value"
name="value"
bind:value
step="1"
class="w-full border px-3 py-1.5 text-sm {fieldErrors.value
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.value}
<p class="mt-1 text-xs text-red-600">{fieldErrors.value}</p>
{/if}
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
@@ -1,18 +0,0 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
const config = getModelConfig('subscription-levels')!;
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
}
};
@@ -1,90 +0,0 @@
<script lang="ts">
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import LevelForm from './LevelForm.svelte';
let { data, form }: PageProps = $props();
const config = getModelConfig('subscription-levels')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let formError = $state('');
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<LevelForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
{/if}
@@ -1,95 +0,0 @@
<script lang="ts">
import { enhance } from '$app/forms';
let {
record = {} as Record<string, unknown>,
fieldErrors = {} as Record<string, string>,
formError = '',
action = '?/upsert',
submitLabel = 'Save'
}: {
record?: Record<string, unknown>;
fieldErrors?: Record<string, string>;
formError?: string;
action?: string;
submitLabel?: string;
} = $props();
let name = $state(String(record.name ?? ''));
let description = $state(String(record.description ?? ''));
let dirty = $state(false);
$effect(() => {
dirty = false;
});
function markDirty() {
if (record.id) dirty = true;
}
</script>
<form {action} method="POST" use:enhance oninput={markDirty}>
{#if record?.id}
<input type="hidden" name="id" value={String(record.id)} />
{/if}
{#if formError}
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
{formError}
</div>
{/if}
<div class="flex flex-col gap-4">
<div>
<label
for="field-name"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Name
</label>
<input
type="text"
id="field-name"
name="name"
bind:value={name}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.name
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.name}
<p class="mt-1 text-xs text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div>
<label
for="field-description"
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Description
</label>
<input
type="text"
id="field-description"
name="description"
bind:value={description}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.description
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
/>
{#if fieldErrors.description}
<p class="mt-1 text-xs text-red-600">{fieldErrors.description}</p>
{/if}
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="submit"
disabled={!!record?.id && !dirty}
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
{submitLabel}
</button>
</div>
</div>
</form>
@@ -1,18 +1,62 @@
import type { PageServerLoad } from './$types';
import { getModelConfig } from '$lib/svelte-admin/registry';
import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud';
import { prisma } from '$lib/db';
import { superValidate } from 'sveltekit-superforms/server';
import { zod4 } from 'sveltekit-superforms/adapters';
import { z } from 'zod';
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
const config = getModelConfig('user-roles')!;
const UserRoleSchema = z.object({
name: z.string().min(1, 'Required').max(100, 'Too long')
});
export const load: PageServerLoad = async ({ locals }) => {
return loadRecords(locals, config);
export const load: PageServerLoad = async () => {
const records = await prisma.userRole.findMany({ orderBy: { id: 'asc' } });
return { records: records as unknown as Record<string, unknown>[] };
};
export const actions = {
upsert: async ({ request, locals }) => {
return upsertRecord(request, config, locals);
upsert: async ({ request }) => {
const formData = await request.formData();
const form = await superValidate(formData, zod4(UserRoleSchema));
if (!form.valid) {
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
form.errors as Record<string, unknown>
);
return { success: false, action: 'upsert', error: formError, fieldErrors };
}
const rawId = formData.get('id')?.toString();
const data = { name: form.data.name };
try {
if (rawId) {
await prisma.userRole.update({ where: { id: Number(rawId) }, data });
} else {
await prisma.userRole.create({ data });
}
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
return { success: false, action: 'upsert', error: 'A role with that name already exists.' };
}
throw e;
}
return { success: true, action: 'upsert' };
},
delete: async ({ request, locals }) => {
return deleteRecord(request, config, locals);
delete: async ({ request }) => {
const formData = await request.formData();
const rawId = formData.get('id')?.toString();
if (!rawId) return { success: false, error: 'Missing id' };
try {
await prisma.userRole.delete({ where: { id: Number(rawId) } });
} catch (e: unknown) {
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2003') {
return { success: false, error: 'Cannot delete: this role is assigned to users.' };
}
throw e;
}
return { success: true, action: 'delete' };
}
};
@@ -1,90 +1,148 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { PageProps } from './$types';
import type { ActionResult } from '$lib/svelte-admin/types';
import AdminTable from '$lib/components/admin/AdminTable.svelte';
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
import AdminModal from '$lib/components/admin/AdminModal.svelte';
import Toast from '$lib/components/admin/Toast.svelte';
import { getModelConfig } from '$lib/svelte-admin/registry';
import UserRoleForm from './UserRoleForm.svelte';
type ActionResult = {
success?: boolean;
action?: string;
error?: string;
fieldErrors?: Record<string, string>;
} | null;
let { data, form }: PageProps = $props();
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const config = getModelConfig('user-roles')!;
const canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
const columns: DataTableColumn[] = [
{ label: 'Id', value: (r) => r.id },
{ label: 'Name', value: (r) => r.name }
];
let modalOpen = $state(false);
let editingRecord = $state<Record<string, unknown> | null>(null);
let toastMessage = $state('');
let toastType = $state<'success' | 'error'>('success');
let fieldErrors = $state<Record<string, string>>({});
let name = $state('');
let formError = $state('');
let fieldErrors = $state<Record<string, string>>({});
let successMessage = $state('');
let lastForm = $state<ActionResult>(null);
$effect(() => {
if (form && 'success' in (form as any)) {
const result = form as any;
if (result.success) {
toastMessage = 'Saved successfully';
toastType = 'success';
closeModal();
} else {
formError = result.error ?? '';
fieldErrors = result.fieldErrors ?? {};
}
const rec = editingRecord ?? {};
name = String(rec.name ?? '');
if (!editingRecord) {
formError = '';
fieldErrors = {};
successMessage = '';
}
});
$effect(() => {
if (!form || form === lastForm) return;
lastForm = form;
if (form.success) {
formError = '';
fieldErrors = {};
successMessage = editingRecord ? 'Role updated' : 'Role created';
setTimeout(() => {
modalOpen = false;
successMessage = '';
}, 1200);
} else {
successMessage = '';
formError = form.error ?? '';
fieldErrors = form.fieldErrors ?? {};
}
});
function openCreate() {
editingRecord = null;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function openEdit(record: Record<string, unknown>) {
editingRecord = record;
formError = '';
fieldErrors = {};
lastForm = null;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
editingRecord = null;
formError = '';
fieldErrors = {};
}
</script>
<div class="p-6">
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
<AdminTable
<div>
<DataTable
title="User Roles"
{columns}
records={data.records}
listFields={config.listFields}
searchFields={config.searchFields}
fieldTypes={config.fieldTypes}
singular={config.displayNameSingular}
newLabel="New Role"
searchPlaceholder="Search roles..."
onRowClick={openEdit}
onNew={openCreate}
/>
</div>
<AdminModal
title={editingRecord
? `Edit ${config.displayNameSingular}`
: `Create ${config.displayNameSingular}`}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
<UserRoleForm
record={editingRecord ?? {}}
{fieldErrors}
{formError}
submitLabel={editingRecord ? 'Update' : 'Create'}
/>
</AdminModal>
{#if modalOpen}
<AdminModal
title={editingRecord ? 'Edit User Role' : 'Create User Role'}
open={modalOpen}
onClose={closeModal}
{canDelete}
recordId={editingRecord?.id as string | undefined}
>
{#if successMessage}
<div
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
>
{successMessage}
</div>
{/if}
{#if formError}
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{formError}
</div>
{/if}
{#if toastMessage}
<Toast message={toastMessage} type={toastType} />
<form method="POST" action="?/upsert" use:enhance class="flex flex-col gap-4">
{#if editingRecord}
<input type="hidden" name="id" value={String(editingRecord.id)} />
{/if}
<div>
<label for="field-name" class="mb-1 block text-sm font-medium text-slate-700">Name</label>
<input
id="field-name"
type="text"
name="name"
required
bind:value={name}
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
/>
{#if fieldErrors.name}
<p class="mt-1 text-sm text-red-600">{fieldErrors.name}</p>
{/if}
</div>
<div class="mt-2 flex justify-end gap-3">
<button
type="button"
onclick={closeModal}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
Cancel
</button>
<button
type="submit"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{editingRecord ? 'Save Role' : 'Create Role'}
</button>
</div>
</form>
</AdminModal>
{/if}

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