Merge branch 'dev'
Build and Push to Digital Ocean Container Registry / build-and-push (push) Successful in 3m23s
Build and Push to Digital Ocean Container Registry / migrate (push) Successful in 41s

This commit is contained in:
NeilClack
2026-09-11 19:07:21 -05:00
32 changed files with 7951 additions and 8 deletions
+20
View File
@@ -97,6 +97,26 @@ Requires API key.
--- ---
# Schedule (v2)
Endpoint:
GET /api/v2/schedule?email=<email>&licenseKey=<key>
Authenticates with the paired `email` + `licenseKey` query parameters and
validates only that the key belongs to the account (not that the license is
active/entitled). Returns the user's Blocks with start/end times and the IDs of
the selected Proclamations (content is fetched separately via
`/api/v2/proclamations`). Each block also carries `dynamicAudio`; when true, the
kiosk enables dynamic audio for the block's duration, overriding any audio
selections made on the device while the block is active. Overnight blocks (end
clock time before start) are supported and flagged with `overnight: true` plus a
wrapping `durationMinutes`. Rate limited to 10 requests per minute per IP.
Failure messages follow the existing v2 shape:
`not-authorized` or `this key doesn't appear to belong to you`.
---
# GHL Account Creation Webhook # GHL Account Creation Webhook
Endpoint: Endpoint:
+145
View File
@@ -31,6 +31,8 @@ tags:
description: Proclamation content, optionally scoped by license entitlement. description: Proclamation content, optionally scoped by license entitlement.
- name: License - name: License
description: License key validation. description: License key validation.
- name: Schedule
description: A user's daily listening schedule, consumed by the kiosk.
- name: Automations - name: Automations
description: Signed machine-to-machine webhooks. description: Signed machine-to-machine webhooks.
paths: paths:
@@ -230,6 +232,83 @@ paths:
application/json: application/json:
schema: schema:
$ref: '#/components/schemas/ErrorMessage' $ref: '#/components/schemas/ErrorMessage'
/api/v2/schedule:
get:
tags: [Schedule]
summary: Get a user's listening schedule (query-param auth)
description: |
Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates via
the paired `email` + `licenseKey` **query parameters** and validates only
that the key belongs to the account for that email. License
active/entitlement is deliberately not checked here: an invalid license
would already lock the kiosk out, so this endpoint only checks the
pairing.
Returns every Block with its start/end times and the ordered list of
selected Proclamation IDs. Overnight blocks (an end clock time before
the start) are supported and flagged with `overnight: true` and a
wrapping `durationMinutes`. Each block also carries `dynamicAudio`;
when true, the kiosk enables dynamic audio for the block's duration,
overriding any audio selections made on the device. Proclamation
content is not included; the kiosk fetches it separately via
`/api/v2/proclamations`.
operationId: getSchedule
parameters:
- in: query
name: email
required: true
schema:
type: string
format: email
description: Account email.
- in: query
name: licenseKey
required: true
schema:
type: string
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
description: License key. The legacy alias `key` is also accepted.
responses:
'200':
description: The user's schedule (possibly empty).
content:
application/json:
schema:
$ref: '#/components/schemas/ScheduleResponse'
example:
email: jane@example.com
blocks:
- id: 3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f
startTime: '21:00'
startMinutes: 1260
endTime: '09:00'
endMinutes: 540
overnight: true
durationMinutes: 720
dynamicAudio: true
proclamationIds:
- b40fdcb9-703c-420e-af32-a081964c6f08
- 7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b
'400':
description: |
Missing/invalid parameters, unknown key or user, or the key does not
belong to the email's account. Possible messages: `not-authorized`,
`this key doesn't appear to belong to you`.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
'429':
description: Rate limit exceeded (10 requests per minute per IP).
headers:
Retry-After:
schema:
type: integer
description: Seconds to wait before retrying.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorMessage'
/api/v2/automations/create/account: /api/v2/automations/create/account:
post: post:
tags: [Automations] tags: [Automations]
@@ -436,6 +515,72 @@ components:
email_address: email_address:
type: string type: string
format: email format: email
ScheduleBlock:
type: object
required:
[
id,
startTime,
startMinutes,
endTime,
endMinutes,
overnight,
durationMinutes,
dynamicAudio,
proclamationIds
]
properties:
id:
type: string
format: uuid
startTime:
type: string
example: '06:00'
description: Wall-clock start time as `HH:MM`.
startMinutes:
type: integer
minimum: 0
maximum: 1440
description: Minutes from midnight.
endTime:
type: string
example: '08:00'
description: Wall-clock end time as `HH:MM`.
endMinutes:
type: integer
minimum: 0
maximum: 1440
description: Minutes from midnight.
overnight:
type: boolean
description: True when the end clock time is at or before the start, i.e. the block crosses midnight.
durationMinutes:
type: integer
minimum: 1
description: Block length in minutes. An overnight block counts the wrap past midnight as the next day.
dynamicAudio:
type: boolean
description: |
When true, dynamic audio is enabled for the block's duration, overriding any
audio selections made on the device while the block is active.
proclamationIds:
type: array
description: IDs of the selected Proclamations, in play order.
items:
type: string
format: uuid
ScheduleResponse:
type: object
required: [email, blocks]
properties:
email:
type: string
format: email
blocks:
type: array
maxItems: 4
items:
$ref: '#/components/schemas/ScheduleBlock'
OnboardingRequest: OnboardingRequest:
type: object type: object
required: [email] required: [email]
@@ -0,0 +1,53 @@
-- CreateTable
CREATE TABLE "Schedule" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Schedule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Block" (
"id" TEXT NOT NULL,
"scheduleId" TEXT NOT NULL,
"startMinutes" INTEGER NOT NULL,
"endMinutes" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Block_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BlockProclamations" (
"blockId" TEXT NOT NULL,
"proclamationId" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BlockProclamations_pkey" PRIMARY KEY ("blockId","proclamationId")
);
-- CreateIndex
CREATE UNIQUE INDEX "Schedule_userId_key" ON "Schedule"("userId");
-- CreateIndex
CREATE INDEX "Block_scheduleId_idx" ON "Block"("scheduleId");
-- CreateIndex
CREATE INDEX "BlockProclamations_proclamationId_idx" ON "BlockProclamations"("proclamationId");
-- AddForeignKey
ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Block" ADD CONSTRAINT "Block_scheduleId_fkey" FOREIGN KEY ("scheduleId") REFERENCES "Schedule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BlockProclamations" ADD CONSTRAINT "BlockProclamations_blockId_fkey" FOREIGN KEY ("blockId") REFERENCES "Block"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "BlockProclamations" ADD CONSTRAINT "BlockProclamations_proclamationId_fkey" FOREIGN KEY ("proclamationId") REFERENCES "Proclamation"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Block" ADD COLUMN "dynamicAudio" BOOLEAN NOT NULL DEFAULT false;
+51 -1
View File
@@ -28,6 +28,7 @@ model User {
profiles Profile[] profiles Profile[]
licenseKeys LicenseKey[] licenseKeys LicenseKey[]
userProclamations UserProclamations[] userProclamations UserProclamations[]
schedule Schedule?
@@unique([email]) @@unique([email])
@@map("user") @@map("user")
@@ -130,6 +131,7 @@ model Proclamation {
groupId Int groupId Int
group ProclamationGroup @relation(fields: [groupId], references: [id]) group ProclamationGroup @relation(fields: [groupId], references: [id])
userProclamations UserProclamations[] userProclamations UserProclamations[]
blockProclamations BlockProclamations[]
// Legacy subscription-level FK. Retained in the database but unmanaged; the // Legacy subscription-level FK. Retained in the database but unmanaged; the
// FK constraint is dropped and the value is left NULL. Hidden from the client. // FK constraint is dropped and the value is left NULL. Hidden from the client.
subscriptionLevelId Int? @ignore subscriptionLevelId Int? @ignore
@@ -186,7 +188,7 @@ model ProclamationCategoryLegacy {
// `@@ignore` so Prisma remains aware of the table but it is excluded from the // `@@ignore` so Prisma remains aware of the table but it is excluded from the
// generated client and Prisma's own migrations. // generated client and Prisma's own migrations.
model ProclamationCategorySubscriptionLevelLegacy { model ProclamationCategorySubscriptionLevelLegacy {
categoryId Int categoryId Int
subscriptionLevelId Int subscriptionLevelId Int
@@id([categoryId, subscriptionLevelId]) @@id([categoryId, subscriptionLevelId])
@@ -253,3 +255,51 @@ model AuditLog {
@@index([page]) @@index([page])
@@map("AuditLog") @@map("AuditLog")
} }
// A user's daily listening schedule. Exactly one per user (enforced by the
// unique userId); contains 0-4 time Blocks. Times are wall-clock minutes from
// midnight (0-1440), interpreted by the kiosk in the user's configured
// timezone. The portal never stores a timezone.
model Schedule {
id String @id @default(uuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
blocks Block[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// A single non-overlapping time range within a Schedule, plus the ordered set
// of selected Proclamations to play during it. Overnight ranges are disallowed
// in v1, so startMinutes is always less than endMinutes.
model Block {
id String @id @default(uuid())
scheduleId String
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
startMinutes Int
endMinutes Int
// When true, the kiosk enables dynamic audio for the duration of this block,
// overriding any audio selections made on the device.
dynamicAudio Boolean @default(false)
proclamations BlockProclamations[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([scheduleId])
}
// Join table: the Proclamations selected for a Block. Carries the Proclamation
// content for playback via the API; the portal UI only ever reads
// title/category/group.
model BlockProclamations {
blockId String
block Block @relation(fields: [blockId], references: [id], onDelete: Cascade)
proclamationId String
proclamation Proclamation @relation(fields: [proclamationId], references: [id])
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([blockId, proclamationId])
@@index([proclamationId])
}
@@ -0,0 +1,437 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import ProclamationBrowser from './ProclamationBrowser.svelte';
import {
MINUTE_STEP,
formatTime,
isOvernight,
validateBlock,
type BlockSibling
} from '$lib/schedule-utils';
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
open = false,
mode = 'create',
blockId,
initialStartMinutes = 9 * 60,
initialEndMinutes = 10 * 60,
initialProclamationIds = [],
initialDynamicAudio = false,
categories = [],
groups = [],
proclamations = [],
existingBlocks = [],
createAction = '?/createBlock',
updateAction = '?/updateBlock',
deleteAction = '?/deleteBlock',
onClose,
onSaved
}: {
open?: boolean;
mode?: 'create' | 'edit';
blockId?: string;
initialStartMinutes?: number;
initialEndMinutes?: number;
initialProclamationIds?: string[];
initialDynamicAudio?: boolean;
categories?: BrowserCategory[];
groups?: BrowserGroup[];
proclamations?: BrowserProclamation[];
existingBlocks?: BlockSibling[];
createAction?: string;
updateAction?: string;
deleteAction?: string;
onClose?: () => void;
onSaved?: () => void;
} = $props();
let step = $state<'details' | 'browse'>('details');
let startHour = $state('9');
let startMinute = $state('0');
let startPeriod = $state<'AM' | 'PM'>('AM');
let endHour = $state('10');
let endMinute = $state('0');
let endPeriod = $state<'AM' | 'PM'>('AM');
let selectedIds = $state<string[]>([]);
let dynamicAudio = $state(false);
let error = $state('');
const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1));
const periodOptions: ('AM' | 'PM')[] = ['AM', 'PM'];
function minuteOptions(current: string): string[] {
const base = Array.from({ length: 60 / MINUTE_STEP }, (_, i) => String(i * MINUTE_STEP));
if (base.includes(current)) return base;
return [...base, current].sort((a, b) => Number(a) - Number(b));
}
function to12Hour(totalMinutes: number): { hour: number; minute: number; period: 'AM' | 'PM' } {
const hours24 = Math.floor(totalMinutes / 60) % 24;
return {
hour: hours24 % 12 === 0 ? 12 : hours24 % 12,
minute: totalMinutes % 60,
period: hours24 < 12 ? 'AM' : 'PM'
};
}
function to24Hour(hour12: string, minute: string, period: 'AM' | 'PM'): number {
const hours = (Number(hour12) % 12) + (period === 'PM' ? 12 : 0);
return (hours * 60 + Number(minute)) | 0;
}
// Re-seed the fields whenever the modal is (re)opened.
$effect(() => {
if (!open) return;
const start = to12Hour(initialStartMinutes);
startHour = String(start.hour);
startMinute = String(start.minute);
startPeriod = start.period;
const end = to12Hour(initialEndMinutes);
endHour = String(end.hour);
endMinute = String(end.minute);
endPeriod = end.period;
selectedIds = [...initialProclamationIds];
dynamicAudio = initialDynamicAudio;
error = '';
step = 'details';
});
const startMinutes = $derived(to24Hour(startHour, startMinute, startPeriod));
const endMinutes = $derived(to24Hour(endHour, endMinute, endPeriod));
const overnight = $derived(
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
);
const siblings = $derived(existingBlocks.filter((block) => block.id !== blockId));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
function validate(): string | null {
const result = validateBlock(
{ startMinutes, endMinutes, proclamationIds: selectedIds },
siblings,
{ isCreate: mode === 'create' }
);
return result.ok ? null : result.error;
}
const handleSubmit: SubmitFunction = ({ formData, cancel }) => {
const intent = formData.get('intent');
if (intent !== 'delete') {
const validationError = validate();
if (validationError) {
error = validationError;
cancel();
return;
}
}
error = '';
return async ({ result, update }) => {
if (result.type === 'failure') {
const data = result.data as { error?: string } | undefined;
error = data?.error ?? 'Unable to save block.';
await update({ reset: false });
return;
}
if (result.type === 'error') {
error = 'Unable to save block.';
return;
}
await update();
onSaved?.();
};
};
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose?.();
}
</script>
{#if open}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4"
onkeydown={handleKeydown}
role="presentation"
>
<button
type="button"
aria-label="Close dialog"
class="absolute inset-0 h-full w-full cursor-default"
onclick={() => onClose?.()}
></button>
<div
role="dialog"
aria-modal="true"
aria-label={mode === 'edit' ? 'Edit block' : 'Create block'}
class="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900"
>
<div
class="flex shrink-0 items-center justify-between gap-3 border-b border-slate-200 px-6 py-4 dark:border-slate-800"
>
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">
{step === 'browse'
? 'Select Proclamations'
: mode === 'edit'
? 'Edit Block'
: 'Create Block'}
</h2>
<button
type="button"
onclick={() => onClose?.()}
aria-label="Close"
class="rounded-md p-1.5 text-2xl leading-none text-slate-400 transition hover:bg-slate-100 hover:text-red-600 dark:text-slate-500 dark:hover:bg-slate-800"
>
&times;
</button>
</div>
<form
method="POST"
action={mode === 'edit' ? updateAction : createAction}
use:enhance={handleSubmit}
class="flex min-h-0 flex-1 flex-col"
>
{#if mode === 'edit' && blockId}
<input type="hidden" name="id" value={blockId} />
{/if}
<input type="hidden" name="startMinutes" value={startMinutes} />
<input type="hidden" name="endMinutes" value={endMinutes} />
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
<input type="hidden" name="dynamicAudio" value={dynamicAudio ? 'true' : 'false'} />
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
{#if error}
<div
class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-900 dark:bg-red-950/50 dark:text-red-400"
role="alert"
>
{error}
</div>
{/if}
{#if step === 'details'}
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
<div class="flex flex-col gap-4">
<div>
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>Start time</span
>
<div class="flex items-center gap-2">
<select
aria-label="Start hour"
onchange={(e) => (startHour = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each hourOptions as hour (hour)}
<option value={hour} selected={startHour === hour}>{hour}</option>
{/each}
</select>
<span class="text-slate-400">:</span>
<select
aria-label="Start minute"
onchange={(e) => (startMinute = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each minuteOptions(startMinute) as minute (minute)}
<option value={minute} selected={startMinute === minute}>
{minute.padStart(2, '0')}
</option>
{/each}
</select>
<select
aria-label="Start period"
onchange={(e) => (startPeriod = e.currentTarget.value as 'AM' | 'PM')}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each periodOptions as period (period)}
<option value={period} selected={startPeriod === period}>{period}</option>
{/each}
</select>
</div>
</div>
<div>
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
>End time</span
>
<div class="flex items-center gap-2">
<select
aria-label="End hour"
onchange={(e) => (endHour = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each hourOptions as hour (hour)}
<option value={hour} selected={endHour === hour}>{hour}</option>
{/each}
</select>
<span class="text-slate-400">:</span>
<select
aria-label="End minute"
onchange={(e) => (endMinute = e.currentTarget.value)}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each minuteOptions(endMinute) as minute (minute)}
<option value={minute} selected={endMinute === minute}>
{minute.padStart(2, '0')}
</option>
{/each}
</select>
<select
aria-label="End period"
onchange={(e) => (endPeriod = e.currentTarget.value as 'AM' | 'PM')}
class="rounded-md border border-slate-300 bg-white px-2 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
>
{#each periodOptions as period (period)}
<option value={period} selected={endPeriod === period}>{period}</option>
{/each}
</select>
</div>
</div>
<div>
<div class="flex items-center justify-between gap-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
Proclamations ({selectedTitles.length})
</span>
<button
type="button"
onclick={() => (step = 'browse')}
class="rounded-md border border-indigo-200 bg-indigo-50 px-3 py-1.5 text-sm font-medium text-indigo-700 transition hover:bg-indigo-100 dark:border-indigo-900 dark:bg-indigo-950/50 dark:text-indigo-300"
>
Browse proclamations
</button>
</div>
</div>
<div class="flex items-center gap-2">
<label
class="flex items-center gap-2 text-sm font-medium text-slate-700 dark:text-slate-300"
>
<input
type="checkbox"
checked={dynamicAudio}
onchange={(e) => (dynamicAudio = e.currentTarget.checked)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
Enable Dynamic Audio
</label>
<span class="group relative inline-flex">
<button
type="button"
aria-label="What is Dynamic Audio?"
aria-describedby="dynamic-audio-help"
class="flex h-5 w-5 items-center justify-center rounded-full border border-slate-300 text-xs font-semibold text-slate-500 transition hover:border-indigo-400 hover:text-indigo-600 focus:border-indigo-500 focus:outline-none dark:border-slate-600 dark:text-slate-400"
>
?
</button>
<span
id="dynamic-audio-help"
role="tooltip"
class="pointer-events-none absolute bottom-full left-1/2 z-20 mb-2 w-64 -translate-x-1/2 rounded-md bg-slate-900 px-3 py-2 text-xs font-normal text-white opacity-0 shadow-lg transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 dark:bg-slate-700"
>
Dynamic audio will be enabled for this block, while this block is playing.
This will override any audio selections you have made on your device while
this block is active.
</span>
</span>
</div>
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3>
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
{formatTime(startMinutes)} – {formatTime(endMinutes)}{overnight
? ' (next day)'
: ''}
</p>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations selected.
</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title (title)}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
{:else}
<ProclamationBrowser
{categories}
{groups}
{proclamations}
{selectedIds}
onChange={(ids) => (selectedIds = ids)}
/>
{/if}
</div>
<div
class="flex shrink-0 items-center justify-between gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
>
<div class="flex items-center gap-3">
{#if step === 'details' && mode === 'edit' && blockId}
<button
type="submit"
name="intent"
value="delete"
formaction={deleteAction}
class="rounded-md bg-red-50 px-3 py-1.5 text-sm font-medium text-red-600 ring-1 ring-red-200 transition ring-inset hover:bg-red-600 hover:text-white dark:bg-red-950/50 dark:text-red-400 dark:ring-red-900"
>
Delete
</button>
{/if}
<button
type="button"
onclick={() => onClose?.()}
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300 dark:hover:bg-slate-800"
>
Cancel
</button>
</div>
<div class="flex items-center gap-3">
{#if step === 'browse'}
<button
type="button"
onclick={() => (step = 'details')}
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
Accept
</button>
{:else}
<button
type="submit"
name="intent"
value="save"
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
>
{mode === 'edit' ? 'Save Block' : 'Create Block'}
</button>
{/if}
</div>
</div>
</form>
</div>
</div>
{/if}
@@ -0,0 +1,193 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
const { submitRef } = vi.hoisted(() => ({
submitRef: {
current: null as unknown as (opts: { formData: FormData; cancel?: () => void }) => unknown
}
}));
vi.mock('$app/forms', () => ({
enhance: (_node: unknown, submit: unknown) => {
submitRef.current = submit as typeof submitRef.current;
return () => {};
}
}));
import BlockModal from './BlockModal.svelte';
const categories = [{ id: 1, name: 'Physical' }];
const groups = [{ id: 1, name: 'Prayers' }];
const proclamations = [
{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(BlockModal, {
open: true,
mode: 'create',
categories,
groups,
proclamations,
existingBlocks: [],
initialStartMinutes: 6 * 60,
initialEndMinutes: 8 * 60,
initialProclamationIds: ['p1'],
onClose: () => {},
onSaved: () => {},
...overrides
});
}
function submit() {
const form = document.querySelector('form') as HTMLFormElement;
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
}
describe('BlockModal', () => {
it('uses separate hour and minute selects for start and end', async () => {
mount();
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Start minute$/ }))
.toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toBeInTheDocument();
});
it('pre-fills the selects from the initial times', async () => {
mount({ initialStartMinutes: 7 * 60 + 30, initialEndMinutes: 9 * 60 + 15 });
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('7');
await expect.element(page.getByRole('combobox', { name: /^Start minute$/ })).toHaveValue('30');
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('9');
await expect.element(page.getByRole('combobox', { name: /^End minute$/ })).toHaveValue('15');
});
it('uses a 12-hour clock with an AM/PM select for each time', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 0 });
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toHaveValue('9');
await expect.element(page.getByRole('combobox', { name: /^Start period$/ })).toHaveValue('PM');
await expect.element(page.getByRole('combobox', { name: /^End hour$/ })).toHaveValue('12');
await expect.element(page.getByRole('combobox', { name: /^End period$/ })).toHaveValue('AM');
// No 24-hour option exists any more.
await expect
.element(page.getByRole('combobox', { name: /^Start hour$/ }))
.not.toHaveValue('21');
});
it('recomputes the time when the AM/PM switch changes', async () => {
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
const period = (await page
.getByRole('combobox', { name: /^End period$/ })
.element()) as HTMLSelectElement;
period.value = 'PM';
period.dispatchEvent(new Event('change', { bubbles: true }));
await expect.element(page.getByText('9:00 AM – 10:00 PM')).toBeInTheDocument();
});
it('shows the review times and selected proclamation titles', async () => {
mount();
await expect.element(page.getByText('6:00 AM – 8:00 AM')).toBeInTheDocument();
await expect.element(page.getByText('Peace')).toBeInTheDocument();
});
it('marks an overnight block in the review', async () => {
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 9 * 60 });
await expect.element(page.getByText('9:00 PM – 9:00 AM (next day)')).toBeInTheDocument();
});
it('rejects an overlapping time range with the specific error', async () => {
mount({
existingBlocks: [{ id: 'b1', startMinutes: 6 * 60, endMinutes: 8 * 60 }],
initialStartMinutes: 7 * 60,
initialEndMinutes: 9 * 60
});
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect
.element(page.getByText(/This overlaps with your 6:00 AM–8:00 AM block\./))
.toBeInTheDocument();
});
it('requires at least one proclamation', async () => {
mount({ initialProclamationIds: [] });
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
submit();
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
});
it('does not show an error for a valid selection', async () => {
mount();
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
const result = submit();
expect(typeof result).toBe('function');
await expect.element(page.getByRole('alert')).not.toBeInTheDocument();
});
it('shows Cancel with Accept in the browse step, and Accept returns to the details', async () => {
mount();
const browse = page.getByRole('button', { name: 'Browse proclamations' });
await expect.element(browse).toBeInTheDocument();
(browse.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
const accept = page.getByRole('button', { name: 'Accept' });
await expect.element(accept).toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Create Block' }))
.not.toBeInTheDocument();
(accept.element() as HTMLElement).click();
await expect.element(page.getByRole('button', { name: 'Create Block' })).toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Accept' })).not.toBeInTheDocument();
});
it('offers an Enable Dynamic Audio checkbox with a help tooltip', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
await expect
.element(page.getByText(/Dynamic audio will be enabled for this block/))
.toBeInTheDocument();
});
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
mount({ initialDynamicAudio: true });
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
});
it('toggles the dynamic audio flag in the submitted payload', async () => {
mount();
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
await expect.element(checkbox).toBeInTheDocument();
(checkbox.element() as HTMLInputElement).click();
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
await expect.poll(() => hidden.value).toBe('true');
});
});
@@ -0,0 +1,173 @@
<script lang="ts">
import {
MINUTES_PER_DAY,
SNAP_MINUTES,
blockSegments,
formatTime,
isOvernight,
snapMinutes
} from '$lib/schedule-utils';
type ScheduleBlockView = {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string; name: string }[];
};
let {
blocks = [],
onCreate,
onEdit
}: {
blocks?: ScheduleBlockView[];
onCreate?: (startMinutes: number, endMinutes: number) => void;
onEdit?: (block: ScheduleBlockView) => void;
} = $props();
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
const defaultDuration = 60;
let track = $state<HTMLDivElement>();
let drag = $state<{ anchor: number; current: number } | null>(null);
function minutesFromEvent(event: PointerEvent): number {
if (!track) return 0;
const rect = track.getBoundingClientRect();
const ratio = (event.clientY - rect.top) / rect.height;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
}
function handlePointerDown(event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return;
const minutes = minutesFromEvent(event);
drag = { anchor: minutes, current: minutes };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
event.preventDefault();
}
function handlePointerMove(event: PointerEvent) {
if (!drag) return;
drag = { anchor: drag.anchor, current: minutesFromEvent(event) };
}
function handlePointerUp(event: PointerEvent) {
if (!drag) return;
const { anchor, current } = drag;
drag = null;
(event.currentTarget as HTMLElement).releasePointerCapture?.(event.pointerId);
const start = Math.min(anchor, current);
const end = Math.max(anchor, current);
if (end - start >= SNAP_MINUTES) {
onCreate?.(start, end);
return;
}
// A simple click creates a default-length block starting at that slot.
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
const clickEnd = Math.max(
defaultDuration,
Math.min(MINUTES_PER_DAY, clickStart + defaultDuration)
);
onCreate?.(Math.max(0, clickStart), clickEnd);
}
const preview = $derived.by(() => {
if (!drag) return null;
const start = Math.min(drag.anchor, drag.current);
const end = Math.max(drag.anchor, drag.current);
return {
top: (start / MINUTES_PER_DAY) * 100,
height: ((end - start) / MINUTES_PER_DAY) * 100
};
});
// Overnight blocks render as two segments: start→midnight and midnight→end.
const blockViews = $derived(
blocks.map((block) => ({
block,
overnight: isOvernight(block),
segments: blockSegments(block).map(([segmentStart, segmentEnd]) => ({
top: (segmentStart / MINUTES_PER_DAY) * 100,
height: ((segmentEnd - segmentStart) / MINUTES_PER_DAY) * 100
}))
}))
);
</script>
<div class="flex gap-2">
<div class="relative h-[720px] w-14 shrink-0" aria-hidden="true">
{#each hourMarks as hour (hour)}
<div
class="absolute right-1 -translate-y-1/2 text-xs text-slate-400 dark:text-slate-500"
style={`top:${(hour / 24) * 100}%`}
>
{String(hour).padStart(2, '0')}:00
</div>
{/each}
</div>
<div
bind:this={track}
role="presentation"
class="relative h-[720px] flex-1 cursor-pointer overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
style="touch-action: none;"
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={() => (drag = null)}
>
{#each hourMarks as hour (hour)}
<div
class="pointer-events-none absolute inset-x-0 border-t border-slate-100 dark:border-slate-800"
style={`top:${(hour / 24) * 100}%`}
></div>
{/each}
{#if preview}
<div
class="pointer-events-none absolute inset-x-1 rounded-md bg-indigo-200/60 dark:bg-indigo-700/40"
style={`top:${preview.top}%;height:${preview.height}%`}
></div>
{/if}
{#each blockViews as view (view.block.id)}
{#each view.segments as segment, segmentIndex (`${view.block.id}-${segmentIndex}`)}
<button
type="button"
onpointerdown={(event) => event.stopPropagation()}
onclick={(event) => {
event.stopPropagation();
onEdit?.(view.block);
}}
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
class="absolute inset-x-1 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-2 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/60 dark:text-indigo-200 dark:hover:bg-indigo-900"
style={`top:${segment.top}%;height:${segment.height}%`}
>
{#if segmentIndex === 0}
<span class="block font-semibold">
{formatTime(view.block.startMinutes)} – {formatTime(
view.block.endMinutes
)}{view.overnight ? ' (next day)' : ''}
</span>
{:else}
<span class="block font-semibold">↳ continues</span>
{/if}
<span class="block truncate">
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
</span>
</button>
{/each}
{/each}
{#if blocks.length === 0}
<p
class="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 px-4 text-center text-sm text-slate-400 dark:text-slate-500"
>
Click or drag to add a block.
</p>
{/if}
</div>
</div>
@@ -0,0 +1,211 @@
<script lang="ts">
type BrowserCategory = { id: number; name: string };
type BrowserGroup = { id: number; name: string };
type BrowserProclamation = {
id: string;
name: string;
categoryId: number;
groupId: number | null;
};
let {
categories = [],
groups = [],
proclamations = [],
selectedIds = [],
onChange
}: {
categories: BrowserCategory[];
groups: BrowserGroup[];
proclamations: BrowserProclamation[];
selectedIds: string[];
onChange?: (ids: string[]) => void;
} = $props();
// Categories start collapsed. Groups are pre-expanded, so we track the
// smaller set of groups the user has explicitly collapsed.
let openCategoryIds = $state<number[]>([]);
let closedGroupIds = $state<number[]>([]);
const selectedSet = $derived(new Set(selectedIds));
const nameById = $derived(new Map(proclamations.map((p) => [p.id, p.name])));
const selectedTitles = $derived(
selectedIds
.map((id) => nameById.get(id))
.filter((name): name is string => typeof name === 'string')
);
type GroupView = { group: BrowserGroup; items: BrowserProclamation[] };
type CategoryView = {
category: BrowserCategory;
groups: GroupView[];
ungrouped: BrowserProclamation[];
};
const view = $derived.by<CategoryView[]>(() =>
categories.map((category) => {
const inCategory = proclamations.filter((p) => p.categoryId === category.id);
const groupViews = groups
.map((group) => ({
group,
items: inCategory.filter((p) => p.groupId === group.id)
}))
.filter((groupView) => groupView.items.length > 0);
const ungrouped = inCategory.filter((p) => p.groupId === null || p.groupId === undefined);
return { category, groups: groupViews, ungrouped };
})
);
function toggleCategory(id: number) {
openCategoryIds = openCategoryIds.includes(id)
? openCategoryIds.filter((value) => value !== id)
: [...openCategoryIds, id];
}
function toggleGroup(id: number) {
closedGroupIds = closedGroupIds.includes(id)
? closedGroupIds.filter((value) => value !== id)
: [...closedGroupIds, id];
}
function toggleProclamation(id: string) {
const next = selectedSet.has(id)
? selectedIds.filter((value) => value !== id)
: [...selectedIds, id];
onChange?.(next);
}
</script>
<div class="grid gap-4 md:grid-cols-[minmax(0,1fr)_16rem]">
<div
class="max-h-96 overflow-y-auto rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
>
{#if categories.length === 0}
<p class="p-4 text-sm text-slate-500 dark:text-slate-400">No categories available.</p>
{:else}
<ul class="divide-y divide-slate-100 dark:divide-slate-800">
{#each view as categoryView (categoryView.category.id)}
{@const isCategoryOpen = openCategoryIds.includes(categoryView.category.id)}
<li>
<button
type="button"
aria-expanded={isCategoryOpen}
onclick={() => toggleCategory(categoryView.category.id)}
class="flex w-full items-center justify-between gap-2 px-4 py-3 text-left text-sm font-semibold text-slate-800 transition hover:bg-slate-50 dark:text-slate-200 dark:hover:bg-slate-800"
>
<span>{categoryView.category.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isCategoryOpen ? 'rotate-90' : ''}"
aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg
>
</button>
{#if isCategoryOpen}
<div class="px-4 pb-3">
{#if categoryView.groups.length === 0 && categoryView.ungrouped.length === 0}
<p class="py-2 text-sm text-slate-500 dark:text-slate-400">
No proclamations in this category.
</p>
{/if}
{#each categoryView.groups as groupView (groupView.group.id)}
{@const isGroupOpen = !closedGroupIds.includes(groupView.group.id)}
<div class="mt-2">
<button
type="button"
aria-expanded={isGroupOpen}
onclick={() => toggleGroup(groupView.group.id)}
class="flex w-full items-center justify-between gap-2 rounded-md px-2 py-1.5 text-left text-xs font-semibold tracking-wide text-slate-500 uppercase transition hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800"
>
<span>{groupView.group.name}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0 transition-transform {isGroupOpen ? 'rotate-180' : ''}"
aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg
>
</button>
{#if isGroupOpen}
<ul class="mt-1 space-y-0.5">
{#each groupView.items as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
{/if}
</div>
{/each}
{#if categoryView.ungrouped.length > 0}
<div class="mt-3 border-t border-slate-100 pt-2 dark:border-slate-800">
<ul class="space-y-0.5">
{#each categoryView.ungrouped as proclamation (proclamation.id)}
<li>
<label
class="flex cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-slate-700 transition hover:bg-indigo-50 dark:text-slate-300 dark:hover:bg-indigo-950"
>
<input
type="checkbox"
checked={selectedSet.has(proclamation.id)}
onchange={() => toggleProclamation(proclamation.id)}
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
/>
<span>{proclamation.name}</span>
</label>
</li>
{/each}
</ul>
</div>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
<div
class="rounded-lg border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950"
>
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">
Selected ({selectedTitles.length})
</h3>
{#if selectedTitles.length === 0}
<p class="mt-2 text-sm text-slate-500 dark:text-slate-400">No proclamations selected.</p>
{:else}
<ul class="mt-2 space-y-1">
{#each selectedTitles as title, index (selectedIds[index])}
<li class="text-sm text-slate-700 dark:text-slate-300">{title}</li>
{/each}
</ul>
{/if}
</div>
</div>
@@ -0,0 +1,107 @@
import { page } from 'vitest/browser';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import ProclamationBrowser from './ProclamationBrowser.svelte';
const categories = [
{ id: 1, name: 'Physical' },
{ id: 2, name: 'Mental' },
{ id: 3, name: 'Empty' }
];
const groups = [
{ id: 1, name: 'Prayers' },
{ id: 2, name: 'Scripture' }
];
const proclamations = [
{ id: 'p1', name: 'Healing', categoryId: 1, groupId: 1 },
{ id: 'p2', name: 'Strength', categoryId: 1, groupId: 1 },
{ id: 'p3', name: 'Focus', categoryId: 1, groupId: null },
{ id: 'p4', name: 'Calm', categoryId: 2, groupId: 2 }
];
function mount(overrides: Record<string, unknown> = {}) {
return render(ProclamationBrowser, {
categories,
groups,
proclamations,
selectedIds: [],
onChange: () => {},
...overrides
});
}
async function click(locator: { element: () => HTMLElement | SVGElement }) {
const el = locator.element() as HTMLElement;
el.click();
}
describe('ProclamationBrowser', () => {
it('collapses categories by default', async () => {
mount();
await expect.element(page.getByRole('button', { name: 'Physical' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
});
it('reveals pre-expanded groups and their proclamations when a category opens', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Prayers')).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).toBeInTheDocument();
await expect.element(page.getByRole('checkbox', { name: 'Strength' })).toBeInTheDocument();
});
it('hides groups that have no proclamations in the category', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByText('Scripture')).not.toBeInTheDocument();
});
it('lists ungrouped proclamations below the groups', async () => {
mount();
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Focus' })).toBeInTheDocument();
});
it('reports toggled selections through onChange', async () => {
const onChange = vi.fn();
mount({ selectedIds: ['p1'], onChange });
await click(page.getByRole('button', { name: 'Physical' }));
await click(page.getByRole('checkbox', { name: 'Strength' }));
expect(onChange).toHaveBeenCalledWith(['p1', 'p2']);
await click(page.getByRole('checkbox', { name: 'Healing' }));
expect(onChange).toHaveBeenCalledWith([]);
});
it('shows selected titles in the summary and keeps selection across collapse', async () => {
mount({ selectedIds: ['p1'] });
await expect.element(page.getByText('Selected (1)')).toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const checkbox = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(checkbox.checked).toBe(true);
await click(page.getByRole('button', { name: 'Physical' }));
await expect.element(page.getByRole('checkbox', { name: 'Healing' })).not.toBeInTheDocument();
await click(page.getByRole('button', { name: 'Physical' }));
const reopened = (await page
.getByRole('checkbox', { name: 'Healing' })
.element()) as HTMLInputElement;
expect(reopened.checked).toBe(true);
});
});
+15
View File
@@ -97,3 +97,18 @@ export type ColorPalette = Prisma.ColorPaletteModel
* *
*/ */
export type AuditLog = Prisma.AuditLogModel export type AuditLog = Prisma.AuditLogModel
/**
* Model Schedule
*
*/
export type Schedule = Prisma.ScheduleModel
/**
* Model Block
*
*/
export type Block = Prisma.BlockModel
/**
* Model BlockProclamations
*
*/
export type BlockProclamations = Prisma.BlockProclamationsModel
+15
View File
@@ -121,3 +121,18 @@ export type ColorPalette = Prisma.ColorPaletteModel
* *
*/ */
export type AuditLog = Prisma.AuditLogModel export type AuditLog = Prisma.AuditLogModel
/**
* Model Schedule
*
*/
export type Schedule = Prisma.ScheduleModel
/**
* Model Block
*
*/
export type Block = Prisma.BlockModel
/**
* Model BlockProclamations
*
*/
export type BlockProclamations = Prisma.BlockProclamationsModel
File diff suppressed because one or more lines are too long
@@ -399,7 +399,10 @@ export const ModelName = {
LicenseKey: 'LicenseKey', LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey', ApiKey: 'ApiKey',
ColorPalette: 'ColorPalette', ColorPalette: 'ColorPalette',
AuditLog: 'AuditLog' AuditLog: 'AuditLog',
Schedule: 'Schedule',
Block: 'Block',
BlockProclamations: 'BlockProclamations'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -415,7 +418,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions omit: GlobalOmitOptions
} }
meta: { meta: {
modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette" | "auditLog" modelProps: "user" | "profile" | "address" | "session" | "account" | "userRole" | "verification" | "proclamation" | "proclamationGroup" | "userProclamations" | "solfeggioFrequency" | "proclamationCategory" | "licenseKey" | "apiKey" | "colorPalette" | "auditLog" | "schedule" | "block" | "blockProclamations"
txIsolationLevel: TransactionIsolationLevel txIsolationLevel: TransactionIsolationLevel
} }
model: { model: {
@@ -1603,6 +1606,228 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
} }
} }
} }
Schedule: {
payload: Prisma.$SchedulePayload<ExtArgs>
fields: Prisma.ScheduleFieldRefs
operations: {
findUnique: {
args: Prisma.ScheduleFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload> | null
}
findUniqueOrThrow: {
args: Prisma.ScheduleFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
findFirst: {
args: Prisma.ScheduleFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload> | null
}
findFirstOrThrow: {
args: Prisma.ScheduleFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
findMany: {
args: Prisma.ScheduleFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
create: {
args: Prisma.ScheduleCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
createMany: {
args: Prisma.ScheduleCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.ScheduleCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
delete: {
args: Prisma.ScheduleDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
update: {
args: Prisma.ScheduleUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
deleteMany: {
args: Prisma.ScheduleDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.ScheduleUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.ScheduleUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>[]
}
upsert: {
args: Prisma.ScheduleUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$SchedulePayload>
}
aggregate: {
args: Prisma.ScheduleAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateSchedule>
}
groupBy: {
args: Prisma.ScheduleGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ScheduleGroupByOutputType>[]
}
count: {
args: Prisma.ScheduleCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.ScheduleCountAggregateOutputType> | number
}
}
}
Block: {
payload: Prisma.$BlockPayload<ExtArgs>
fields: Prisma.BlockFieldRefs
operations: {
findUnique: {
args: Prisma.BlockFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload> | null
}
findUniqueOrThrow: {
args: Prisma.BlockFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
findFirst: {
args: Prisma.BlockFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload> | null
}
findFirstOrThrow: {
args: Prisma.BlockFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
findMany: {
args: Prisma.BlockFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
create: {
args: Prisma.BlockCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
createMany: {
args: Prisma.BlockCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.BlockCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
delete: {
args: Prisma.BlockDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
update: {
args: Prisma.BlockUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
deleteMany: {
args: Prisma.BlockDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.BlockUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.BlockUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>[]
}
upsert: {
args: Prisma.BlockUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockPayload>
}
aggregate: {
args: Prisma.BlockAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateBlock>
}
groupBy: {
args: Prisma.BlockGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockGroupByOutputType>[]
}
count: {
args: Prisma.BlockCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockCountAggregateOutputType> | number
}
}
}
BlockProclamations: {
payload: Prisma.$BlockProclamationsPayload<ExtArgs>
fields: Prisma.BlockProclamationsFieldRefs
operations: {
findUnique: {
args: Prisma.BlockProclamationsFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload> | null
}
findUniqueOrThrow: {
args: Prisma.BlockProclamationsFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
findFirst: {
args: Prisma.BlockProclamationsFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload> | null
}
findFirstOrThrow: {
args: Prisma.BlockProclamationsFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
findMany: {
args: Prisma.BlockProclamationsFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
create: {
args: Prisma.BlockProclamationsCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
createMany: {
args: Prisma.BlockProclamationsCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.BlockProclamationsCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
delete: {
args: Prisma.BlockProclamationsDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
update: {
args: Prisma.BlockProclamationsUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
deleteMany: {
args: Prisma.BlockProclamationsDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.BlockProclamationsUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.BlockProclamationsUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>[]
}
upsert: {
args: Prisma.BlockProclamationsUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$BlockProclamationsPayload>
}
aggregate: {
args: Prisma.BlockProclamationsAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateBlockProclamations>
}
groupBy: {
args: Prisma.BlockProclamationsGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockProclamationsGroupByOutputType>[]
}
count: {
args: Prisma.BlockProclamationsCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.BlockProclamationsCountAggregateOutputType> | number
}
}
}
} }
} & { } & {
other: { other: {
@@ -1840,6 +2065,40 @@ export const AuditLogScalarFieldEnum = {
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum] export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
export const ScheduleScalarFieldEnum = {
id: 'id',
userId: 'userId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typeof ScheduleScalarFieldEnum]
export const BlockScalarFieldEnum = {
id: 'id',
scheduleId: 'scheduleId',
startMinutes: 'startMinutes',
endMinutes: 'endMinutes',
dynamicAudio: 'dynamicAudio',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockScalarFieldEnum = (typeof BlockScalarFieldEnum)[keyof typeof BlockScalarFieldEnum]
export const BlockProclamationsScalarFieldEnum = {
blockId: 'blockId',
proclamationId: 'proclamationId',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockProclamationsScalarFieldEnum = (typeof BlockProclamationsScalarFieldEnum)[keyof typeof BlockProclamationsScalarFieldEnum]
export const SortOrder = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
@@ -2096,6 +2355,9 @@ export type GlobalOmitConfig = {
apiKey?: Prisma.ApiKeyOmit apiKey?: Prisma.ApiKeyOmit
colorPalette?: Prisma.ColorPaletteOmit colorPalette?: Prisma.ColorPaletteOmit
auditLog?: Prisma.AuditLogOmit auditLog?: Prisma.AuditLogOmit
schedule?: Prisma.ScheduleOmit
block?: Prisma.BlockOmit
blockProclamations?: Prisma.BlockProclamationsOmit
} }
/* Types for Logging */ /* Types for Logging */
@@ -66,7 +66,10 @@ export const ModelName = {
LicenseKey: 'LicenseKey', LicenseKey: 'LicenseKey',
ApiKey: 'ApiKey', ApiKey: 'ApiKey',
ColorPalette: 'ColorPalette', ColorPalette: 'ColorPalette',
AuditLog: 'AuditLog' AuditLog: 'AuditLog',
Schedule: 'Schedule',
Block: 'Block',
BlockProclamations: 'BlockProclamations'
} as const } as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName] export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -283,6 +286,40 @@ export const AuditLogScalarFieldEnum = {
export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum] export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typeof AuditLogScalarFieldEnum]
export const ScheduleScalarFieldEnum = {
id: 'id',
userId: 'userId',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typeof ScheduleScalarFieldEnum]
export const BlockScalarFieldEnum = {
id: 'id',
scheduleId: 'scheduleId',
startMinutes: 'startMinutes',
endMinutes: 'endMinutes',
dynamicAudio: 'dynamicAudio',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockScalarFieldEnum = (typeof BlockScalarFieldEnum)[keyof typeof BlockScalarFieldEnum]
export const BlockProclamationsScalarFieldEnum = {
blockId: 'blockId',
proclamationId: 'proclamationId',
sortOrder: 'sortOrder',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
export type BlockProclamationsScalarFieldEnum = (typeof BlockProclamationsScalarFieldEnum)[keyof typeof BlockProclamationsScalarFieldEnum]
export const SortOrder = { export const SortOrder = {
asc: 'asc', asc: 'asc',
desc: 'desc' desc: 'desc'
+3
View File
@@ -24,4 +24,7 @@ export type * from './models/LicenseKey.ts'
export type * from './models/ApiKey.ts' export type * from './models/ApiKey.ts'
export type * from './models/ColorPalette.ts' export type * from './models/ColorPalette.ts'
export type * from './models/AuditLog.ts' export type * from './models/AuditLog.ts'
export type * from './models/Schedule.ts'
export type * from './models/Block.ts'
export type * from './models/BlockProclamations.ts'
export type * from './commonInputTypes.ts' export type * from './commonInputTypes.ts'
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -236,6 +236,7 @@ export type ProclamationWhereInput = {
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput> category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput> group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
userProclamations?: Prisma.UserProclamationsListRelationFilter userProclamations?: Prisma.UserProclamationsListRelationFilter
blockProclamations?: Prisma.BlockProclamationsListRelationFilter
} }
export type ProclamationOrderByWithRelationInput = { export type ProclamationOrderByWithRelationInput = {
@@ -249,6 +250,7 @@ export type ProclamationOrderByWithRelationInput = {
category?: Prisma.ProclamationCategoryOrderByWithRelationInput category?: Prisma.ProclamationCategoryOrderByWithRelationInput
group?: Prisma.ProclamationGroupOrderByWithRelationInput group?: Prisma.ProclamationGroupOrderByWithRelationInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
blockProclamations?: Prisma.BlockProclamationsOrderByRelationAggregateInput
} }
export type ProclamationWhereUniqueInput = Prisma.AtLeast<{ export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
@@ -265,6 +267,7 @@ export type ProclamationWhereUniqueInput = Prisma.AtLeast<{
category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput> category?: Prisma.XOR<Prisma.ProclamationCategoryScalarRelationFilter, Prisma.ProclamationCategoryWhereInput>
group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput> group?: Prisma.XOR<Prisma.ProclamationGroupScalarRelationFilter, Prisma.ProclamationGroupWhereInput>
userProclamations?: Prisma.UserProclamationsListRelationFilter userProclamations?: Prisma.UserProclamationsListRelationFilter
blockProclamations?: Prisma.BlockProclamationsListRelationFilter
}, "id" | "name"> }, "id" | "name">
export type ProclamationOrderByWithAggregationInput = { export type ProclamationOrderByWithAggregationInput = {
@@ -301,6 +304,7 @@ export type ProclamationCreateInput = {
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUncheckedCreateInput = { export type ProclamationUncheckedCreateInput = {
@@ -311,6 +315,7 @@ export type ProclamationUncheckedCreateInput = {
categoryId: number categoryId: number
groupId: number groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUpdateInput = { export type ProclamationUpdateInput = {
@@ -321,6 +326,7 @@ export type ProclamationUpdateInput = {
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateInput = { export type ProclamationUncheckedUpdateInput = {
@@ -331,6 +337,7 @@ export type ProclamationUncheckedUpdateInput = {
categoryId?: Prisma.IntFieldUpdateOperationsInput | number categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationCreateManyInput = { export type ProclamationCreateManyInput = {
@@ -551,6 +558,20 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryNestedInput = {
deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[] deleteMany?: Prisma.ProclamationScalarWhereInput | Prisma.ProclamationScalarWhereInput[]
} }
export type ProclamationCreateNestedOneWithoutBlockProclamationsInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutBlockProclamationsInput
connect?: Prisma.ProclamationWhereUniqueInput
}
export type ProclamationUpdateOneRequiredWithoutBlockProclamationsNestedInput = {
create?: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
connectOrCreate?: Prisma.ProclamationCreateOrConnectWithoutBlockProclamationsInput
upsert?: Prisma.ProclamationUpsertWithoutBlockProclamationsInput
connect?: Prisma.ProclamationWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.ProclamationUpdateToOneWithWhereWithoutBlockProclamationsInput, Prisma.ProclamationUpdateWithoutBlockProclamationsInput>, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
}
export type ProclamationCreateWithoutGroupInput = { export type ProclamationCreateWithoutGroupInput = {
id?: string id?: string
name: string name: string
@@ -558,6 +579,7 @@ export type ProclamationCreateWithoutGroupInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUncheckedCreateWithoutGroupInput = { export type ProclamationUncheckedCreateWithoutGroupInput = {
@@ -567,6 +589,7 @@ export type ProclamationUncheckedCreateWithoutGroupInput = {
solfeggioFrequencyId: number solfeggioFrequencyId: number
categoryId: number categoryId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
} }
export type ProclamationCreateOrConnectWithoutGroupInput = { export type ProclamationCreateOrConnectWithoutGroupInput = {
@@ -614,6 +637,7 @@ export type ProclamationCreateWithoutUserProclamationsInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUncheckedCreateWithoutUserProclamationsInput = { export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
@@ -623,6 +647,7 @@ export type ProclamationUncheckedCreateWithoutUserProclamationsInput = {
solfeggioFrequencyId: number solfeggioFrequencyId: number
categoryId: number categoryId: number
groupId: number groupId: number
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
} }
export type ProclamationCreateOrConnectWithoutUserProclamationsInput = { export type ProclamationCreateOrConnectWithoutUserProclamationsInput = {
@@ -648,6 +673,7 @@ export type ProclamationUpdateWithoutUserProclamationsInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = { export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
@@ -657,6 +683,7 @@ export type ProclamationUncheckedUpdateWithoutUserProclamationsInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number groupId?: Prisma.IntFieldUpdateOperationsInput | number
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationCreateWithoutSolfeggioFrequencyInput = { export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
@@ -666,6 +693,7 @@ export type ProclamationCreateWithoutSolfeggioFrequencyInput = {
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = { export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
@@ -675,6 +703,7 @@ export type ProclamationUncheckedCreateWithoutSolfeggioFrequencyInput = {
categoryId: number categoryId: number
groupId: number groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
} }
export type ProclamationCreateOrConnectWithoutSolfeggioFrequencyInput = { export type ProclamationCreateOrConnectWithoutSolfeggioFrequencyInput = {
@@ -710,6 +739,7 @@ export type ProclamationCreateWithoutCategoryInput = {
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsCreateNestedManyWithoutProclamationInput
} }
export type ProclamationUncheckedCreateWithoutCategoryInput = { export type ProclamationUncheckedCreateWithoutCategoryInput = {
@@ -719,6 +749,7 @@ export type ProclamationUncheckedCreateWithoutCategoryInput = {
solfeggioFrequencyId: number solfeggioFrequencyId: number
groupId: number groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
blockProclamations?: Prisma.BlockProclamationsUncheckedCreateNestedManyWithoutProclamationInput
} }
export type ProclamationCreateOrConnectWithoutCategoryInput = { export type ProclamationCreateOrConnectWithoutCategoryInput = {
@@ -747,6 +778,62 @@ export type ProclamationUpdateManyWithWhereWithoutCategoryInput = {
data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutCategoryInput> data: Prisma.XOR<Prisma.ProclamationUpdateManyMutationInput, Prisma.ProclamationUncheckedUpdateManyWithoutCategoryInput>
} }
export type ProclamationCreateWithoutBlockProclamationsInput = {
id?: string
name: string
content: string
solfeggioFrequency: Prisma.SolfeggioFrequencyCreateNestedOneWithoutProclamationsInput
category: Prisma.ProclamationCategoryCreateNestedOneWithoutProclamationsInput
group: Prisma.ProclamationGroupCreateNestedOneWithoutProclamationsInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutProclamationInput
}
export type ProclamationUncheckedCreateWithoutBlockProclamationsInput = {
id?: string
name: string
content: string
solfeggioFrequencyId: number
categoryId: number
groupId: number
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutProclamationInput
}
export type ProclamationCreateOrConnectWithoutBlockProclamationsInput = {
where: Prisma.ProclamationWhereUniqueInput
create: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
}
export type ProclamationUpsertWithoutBlockProclamationsInput = {
update: Prisma.XOR<Prisma.ProclamationUpdateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
create: Prisma.XOR<Prisma.ProclamationCreateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedCreateWithoutBlockProclamationsInput>
where?: Prisma.ProclamationWhereInput
}
export type ProclamationUpdateToOneWithWhereWithoutBlockProclamationsInput = {
where?: Prisma.ProclamationWhereInput
data: Prisma.XOR<Prisma.ProclamationUpdateWithoutBlockProclamationsInput, Prisma.ProclamationUncheckedUpdateWithoutBlockProclamationsInput>
}
export type ProclamationUpdateWithoutBlockProclamationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
}
export type ProclamationUncheckedUpdateWithoutBlockProclamationsInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
content?: Prisma.StringFieldUpdateOperationsInput | string
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
}
export type ProclamationCreateManyGroupInput = { export type ProclamationCreateManyGroupInput = {
id?: string id?: string
name: string name: string
@@ -762,6 +849,7 @@ export type ProclamationUpdateWithoutGroupInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateWithoutGroupInput = { export type ProclamationUncheckedUpdateWithoutGroupInput = {
@@ -771,6 +859,7 @@ export type ProclamationUncheckedUpdateWithoutGroupInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
categoryId?: Prisma.IntFieldUpdateOperationsInput | number categoryId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateManyWithoutGroupInput = { export type ProclamationUncheckedUpdateManyWithoutGroupInput = {
@@ -796,6 +885,7 @@ export type ProclamationUpdateWithoutSolfeggioFrequencyInput = {
category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput category?: Prisma.ProclamationCategoryUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = { export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
@@ -805,6 +895,7 @@ export type ProclamationUncheckedUpdateWithoutSolfeggioFrequencyInput = {
categoryId?: Prisma.IntFieldUpdateOperationsInput | number categoryId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyInput = { export type ProclamationUncheckedUpdateManyWithoutSolfeggioFrequencyInput = {
@@ -830,6 +921,7 @@ export type ProclamationUpdateWithoutCategoryInput = {
solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput solfeggioFrequency?: Prisma.SolfeggioFrequencyUpdateOneRequiredWithoutProclamationsNestedInput
group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput group?: Prisma.ProclamationGroupUpdateOneRequiredWithoutProclamationsNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateWithoutCategoryInput = { export type ProclamationUncheckedUpdateWithoutCategoryInput = {
@@ -839,6 +931,7 @@ export type ProclamationUncheckedUpdateWithoutCategoryInput = {
solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number solfeggioFrequencyId?: Prisma.IntFieldUpdateOperationsInput | number
groupId?: Prisma.IntFieldUpdateOperationsInput | number groupId?: Prisma.IntFieldUpdateOperationsInput | number
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
blockProclamations?: Prisma.BlockProclamationsUncheckedUpdateManyWithoutProclamationNestedInput
} }
export type ProclamationUncheckedUpdateManyWithoutCategoryInput = { export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
@@ -856,10 +949,12 @@ export type ProclamationUncheckedUpdateManyWithoutCategoryInput = {
export type ProclamationCountOutputType = { export type ProclamationCountOutputType = {
userProclamations: number userProclamations: number
blockProclamations: number
} }
export type ProclamationCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type ProclamationCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
userProclamations?: boolean | ProclamationCountOutputTypeCountUserProclamationsArgs userProclamations?: boolean | ProclamationCountOutputTypeCountUserProclamationsArgs
blockProclamations?: boolean | ProclamationCountOutputTypeCountBlockProclamationsArgs
} }
/** /**
@@ -879,6 +974,13 @@ export type ProclamationCountOutputTypeCountUserProclamationsArgs<ExtArgs extend
where?: Prisma.UserProclamationsWhereInput where?: Prisma.UserProclamationsWhereInput
} }
/**
* ProclamationCountOutputType without action
*/
export type ProclamationCountOutputTypeCountBlockProclamationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
where?: Prisma.BlockProclamationsWhereInput
}
export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{ export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
id?: boolean id?: boolean
@@ -891,6 +993,7 @@ export type ProclamationSelect<ExtArgs extends runtime.Types.Extensions.Internal
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs> category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs> group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs> userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
blockProclamations?: boolean | Prisma.Proclamation$blockProclamationsArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["proclamation"]> }, ExtArgs["result"]["proclamation"]>
@@ -933,6 +1036,7 @@ export type ProclamationInclude<ExtArgs extends runtime.Types.Extensions.Interna
category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs> category?: boolean | Prisma.ProclamationCategoryDefaultArgs<ExtArgs>
group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs> group?: boolean | Prisma.ProclamationGroupDefaultArgs<ExtArgs>
userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs> userProclamations?: boolean | Prisma.Proclamation$userProclamationsArgs<ExtArgs>
blockProclamations?: boolean | Prisma.Proclamation$blockProclamationsArgs<ExtArgs>
_count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.ProclamationCountOutputTypeDefaultArgs<ExtArgs>
} }
export type ProclamationIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type ProclamationIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -953,6 +1057,7 @@ export type $ProclamationPayload<ExtArgs extends runtime.Types.Extensions.Intern
category: Prisma.$ProclamationCategoryPayload<ExtArgs> category: Prisma.$ProclamationCategoryPayload<ExtArgs>
group: Prisma.$ProclamationGroupPayload<ExtArgs> group: Prisma.$ProclamationGroupPayload<ExtArgs>
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[] userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
blockProclamations: Prisma.$BlockProclamationsPayload<ExtArgs>[]
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1359,6 +1464,7 @@ export interface Prisma__ProclamationClient<T, Null = never, ExtArgs extends run
category<T extends Prisma.ProclamationCategoryDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationCategoryDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationCategoryClient<runtime.Types.Result.GetResult<Prisma.$ProclamationCategoryPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> category<T extends Prisma.ProclamationCategoryDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationCategoryDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationCategoryClient<runtime.Types.Result.GetResult<Prisma.$ProclamationCategoryPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
group<T extends Prisma.ProclamationGroupDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationGroupDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationGroupClient<runtime.Types.Result.GetResult<Prisma.$ProclamationGroupPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions> group<T extends Prisma.ProclamationGroupDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.ProclamationGroupDefaultArgs<ExtArgs>>): Prisma.Prisma__ProclamationGroupClient<runtime.Types.Result.GetResult<Prisma.$ProclamationGroupPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
userProclamations<T extends Prisma.Proclamation$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Proclamation$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> userProclamations<T extends Prisma.Proclamation$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Proclamation$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
blockProclamations<T extends Prisma.Proclamation$blockProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Proclamation$blockProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$BlockProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -1818,6 +1924,30 @@ export type Proclamation$userProclamationsArgs<ExtArgs extends runtime.Types.Ext
distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[] distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[]
} }
/**
* Proclamation.blockProclamations
*/
export type Proclamation$blockProclamationsArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the BlockProclamations
*/
select?: Prisma.BlockProclamationsSelect<ExtArgs> | null
/**
* Omit specific fields from the BlockProclamations
*/
omit?: Prisma.BlockProclamationsOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.BlockProclamationsInclude<ExtArgs> | null
where?: Prisma.BlockProclamationsWhereInput
orderBy?: Prisma.BlockProclamationsOrderByWithRelationInput | Prisma.BlockProclamationsOrderByWithRelationInput[]
cursor?: Prisma.BlockProclamationsWhereUniqueInput
take?: number
skip?: number
distinct?: Prisma.BlockProclamationsScalarFieldEnum | Prisma.BlockProclamationsScalarFieldEnum[]
}
/** /**
* Proclamation without action * Proclamation without action
*/ */
File diff suppressed because it is too large Load Diff
+148
View File
@@ -246,6 +246,7 @@ export type UserWhereInput = {
profiles?: Prisma.ProfileListRelationFilter profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter userProclamations?: Prisma.UserProclamationsListRelationFilter
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
} }
export type UserOrderByWithRelationInput = { export type UserOrderByWithRelationInput = {
@@ -263,6 +264,7 @@ export type UserOrderByWithRelationInput = {
profiles?: Prisma.ProfileOrderByRelationAggregateInput profiles?: Prisma.ProfileOrderByRelationAggregateInput
licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
schedule?: Prisma.ScheduleOrderByWithRelationInput
} }
export type UserWhereUniqueInput = Prisma.AtLeast<{ export type UserWhereUniqueInput = Prisma.AtLeast<{
@@ -283,6 +285,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
profiles?: Prisma.ProfileListRelationFilter profiles?: Prisma.ProfileListRelationFilter
licenseKeys?: Prisma.LicenseKeyListRelationFilter licenseKeys?: Prisma.LicenseKeyListRelationFilter
userProclamations?: Prisma.UserProclamationsListRelationFilter userProclamations?: Prisma.UserProclamationsListRelationFilter
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
}, "id" | "email"> }, "id" | "email">
export type UserOrderByWithAggregationInput = { export type UserOrderByWithAggregationInput = {
@@ -329,6 +332,7 @@ export type UserCreateInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
} }
export type UserUncheckedCreateInput = { export type UserUncheckedCreateInput = {
@@ -345,6 +349,7 @@ export type UserUncheckedCreateInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
} }
export type UserUpdateInput = { export type UserUpdateInput = {
@@ -361,6 +366,7 @@ export type UserUpdateInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
} }
export type UserUncheckedUpdateInput = { export type UserUncheckedUpdateInput = {
@@ -377,6 +383,7 @@ export type UserUncheckedUpdateInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
} }
export type UserCreateManyInput = { export type UserCreateManyInput = {
@@ -612,6 +619,20 @@ export type UserUpdateOneWithoutLicenseKeysNestedInput = {
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput> update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput>
} }
export type UserCreateNestedOneWithoutScheduleInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
connect?: Prisma.UserWhereUniqueInput
}
export type UserUpdateOneRequiredWithoutScheduleNestedInput = {
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
upsert?: Prisma.UserUpsertWithoutScheduleInput
connect?: Prisma.UserWhereUniqueInput
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutScheduleInput, Prisma.UserUpdateWithoutScheduleInput>, Prisma.UserUncheckedUpdateWithoutScheduleInput>
}
export type UserCreateWithoutProfilesInput = { export type UserCreateWithoutProfilesInput = {
id: string id: string
name: string name: string
@@ -625,6 +646,7 @@ export type UserCreateWithoutProfilesInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
} }
export type UserUncheckedCreateWithoutProfilesInput = { export type UserUncheckedCreateWithoutProfilesInput = {
@@ -640,6 +662,7 @@ export type UserUncheckedCreateWithoutProfilesInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
} }
export type UserCreateOrConnectWithoutProfilesInput = { export type UserCreateOrConnectWithoutProfilesInput = {
@@ -671,6 +694,7 @@ export type UserUpdateWithoutProfilesInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutProfilesInput = { export type UserUncheckedUpdateWithoutProfilesInput = {
@@ -686,6 +710,7 @@ export type UserUncheckedUpdateWithoutProfilesInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
} }
export type UserCreateWithoutSessionsInput = { export type UserCreateWithoutSessionsInput = {
@@ -701,6 +726,7 @@ export type UserCreateWithoutSessionsInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
} }
export type UserUncheckedCreateWithoutSessionsInput = { export type UserUncheckedCreateWithoutSessionsInput = {
@@ -716,6 +742,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
} }
export type UserCreateOrConnectWithoutSessionsInput = { export type UserCreateOrConnectWithoutSessionsInput = {
@@ -747,6 +774,7 @@ export type UserUpdateWithoutSessionsInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutSessionsInput = { export type UserUncheckedUpdateWithoutSessionsInput = {
@@ -762,6 +790,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
} }
export type UserCreateWithoutAccountsInput = { export type UserCreateWithoutAccountsInput = {
@@ -777,6 +806,7 @@ export type UserCreateWithoutAccountsInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
} }
export type UserUncheckedCreateWithoutAccountsInput = { export type UserUncheckedCreateWithoutAccountsInput = {
@@ -792,6 +822,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
} }
export type UserCreateOrConnectWithoutAccountsInput = { export type UserCreateOrConnectWithoutAccountsInput = {
@@ -823,6 +854,7 @@ export type UserUpdateWithoutAccountsInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutAccountsInput = { export type UserUncheckedUpdateWithoutAccountsInput = {
@@ -838,6 +870,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
} }
export type UserCreateWithoutRoleInput = { export type UserCreateWithoutRoleInput = {
@@ -853,6 +886,7 @@ export type UserCreateWithoutRoleInput = {
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
} }
export type UserUncheckedCreateWithoutRoleInput = { export type UserUncheckedCreateWithoutRoleInput = {
@@ -868,6 +902,7 @@ export type UserUncheckedCreateWithoutRoleInput = {
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
} }
export type UserCreateOrConnectWithoutRoleInput = { export type UserCreateOrConnectWithoutRoleInput = {
@@ -923,6 +958,7 @@ export type UserCreateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
} }
export type UserUncheckedCreateWithoutUserProclamationsInput = { export type UserUncheckedCreateWithoutUserProclamationsInput = {
@@ -938,6 +974,7 @@ export type UserUncheckedCreateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
} }
export type UserCreateOrConnectWithoutUserProclamationsInput = { export type UserCreateOrConnectWithoutUserProclamationsInput = {
@@ -969,6 +1006,7 @@ export type UserUpdateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutUserProclamationsInput = { export type UserUncheckedUpdateWithoutUserProclamationsInput = {
@@ -984,6 +1022,7 @@ export type UserUncheckedUpdateWithoutUserProclamationsInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
} }
export type UserCreateWithoutLicenseKeysInput = { export type UserCreateWithoutLicenseKeysInput = {
@@ -999,6 +1038,7 @@ export type UserCreateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
} }
export type UserUncheckedCreateWithoutLicenseKeysInput = { export type UserUncheckedCreateWithoutLicenseKeysInput = {
@@ -1014,6 +1054,7 @@ export type UserUncheckedCreateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
} }
export type UserCreateOrConnectWithoutLicenseKeysInput = { export type UserCreateOrConnectWithoutLicenseKeysInput = {
@@ -1045,6 +1086,7 @@ export type UserUpdateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutLicenseKeysInput = { export type UserUncheckedUpdateWithoutLicenseKeysInput = {
@@ -1060,6 +1102,87 @@ export type UserUncheckedUpdateWithoutLicenseKeysInput = {
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
}
export type UserCreateWithoutScheduleInput = {
id: string
name: string
email: string
emailVerified?: boolean
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
role?: Prisma.UserRoleCreateNestedOneWithoutUsersInput
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
}
export type UserUncheckedCreateWithoutScheduleInput = {
id: string
name: string
email: string
emailVerified?: boolean
image?: string | null
createdAt?: Date | string
updatedAt?: Date | string
roleId?: number | null
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
}
export type UserCreateOrConnectWithoutScheduleInput = {
where: Prisma.UserWhereUniqueInput
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
}
export type UserUpsertWithoutScheduleInput = {
update: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
where?: Prisma.UserWhereInput
}
export type UserUpdateToOneWithWhereWithoutScheduleInput = {
where?: Prisma.UserWhereInput
data: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
}
export type UserUpdateWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
role?: Prisma.UserRoleUpdateOneWithoutUsersNestedInput
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
}
export type UserUncheckedUpdateWithoutScheduleInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
name?: Prisma.StringFieldUpdateOperationsInput | string
email?: Prisma.StringFieldUpdateOperationsInput | string
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
roleId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
} }
export type UserCreateManyRoleInput = { export type UserCreateManyRoleInput = {
@@ -1085,6 +1208,7 @@ export type UserUpdateWithoutRoleInput = {
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
} }
export type UserUncheckedUpdateWithoutRoleInput = { export type UserUncheckedUpdateWithoutRoleInput = {
@@ -1100,6 +1224,7 @@ export type UserUncheckedUpdateWithoutRoleInput = {
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
} }
export type UserUncheckedUpdateManyWithoutRoleInput = { export type UserUncheckedUpdateManyWithoutRoleInput = {
@@ -1194,6 +1319,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs> profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs> licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs> userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
}, ExtArgs["result"]["user"]> }, ExtArgs["result"]["user"]>
@@ -1240,6 +1366,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs> profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs> licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs> userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs> _count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
} }
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
@@ -1258,6 +1385,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
profiles: Prisma.$ProfilePayload<ExtArgs>[] profiles: Prisma.$ProfilePayload<ExtArgs>[]
licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[] licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[]
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[] userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
schedule: Prisma.$SchedulePayload<ExtArgs> | null
} }
scalars: runtime.Types.Extensions.GetPayloadResult<{ scalars: runtime.Types.Extensions.GetPayloadResult<{
id: string id: string
@@ -1668,6 +1796,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
profiles<T extends Prisma.User$profilesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profilesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProfilePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> profiles<T extends Prisma.User$profilesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profilesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProfilePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
licenseKeys<T extends Prisma.User$licenseKeysArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$licenseKeysArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> licenseKeys<T extends Prisma.User$licenseKeysArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$licenseKeysArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
userProclamations<T extends Prisma.User$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null> userProclamations<T extends Prisma.User$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
schedule<T extends Prisma.User$scheduleArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$scheduleArgs<ExtArgs>>): Prisma.Prisma__ScheduleClient<runtime.Types.Result.GetResult<Prisma.$SchedulePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
/** /**
* Attaches callbacks for the resolution and/or rejection of the Promise. * Attaches callbacks for the resolution and/or rejection of the Promise.
* @param onfulfilled The callback to execute when the Promise is resolved. * @param onfulfilled The callback to execute when the Promise is resolved.
@@ -2244,6 +2373,25 @@ export type User$userProclamationsArgs<ExtArgs extends runtime.Types.Extensions.
distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[] distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[]
} }
/**
* User.schedule
*/
export type User$scheduleArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
/**
* Select specific fields to fetch from the Schedule
*/
select?: Prisma.ScheduleSelect<ExtArgs> | null
/**
* Omit specific fields from the Schedule
*/
omit?: Prisma.ScheduleOmit<ExtArgs> | null
/**
* Choose, which related nodes to fetch as well
*/
include?: Prisma.ScheduleInclude<ExtArgs> | null
where?: Prisma.ScheduleWhereInput
}
/** /**
* User without action * User without action
*/ */
+235
View File
@@ -0,0 +1,235 @@
import { describe, it, expect } from 'vitest';
import {
blockDuration,
blockSegments,
findOverlap,
formatTime,
isOvernight,
snapMinutes,
toHm,
validateBlock,
type BlockInput,
type BlockSibling
} from './schedule-utils';
const input = (overrides: Partial<BlockInput> = {}): BlockInput => ({
startMinutes: 6 * 60,
endMinutes: 8 * 60,
proclamationIds: ['p1'],
...overrides
});
const sibling = (startMinutes: number, endMinutes: number, id = 'b1'): BlockSibling => ({
id,
startMinutes,
endMinutes
});
describe('formatTime', () => {
it('formats 12-hour clock labels', () => {
expect(formatTime(0)).toBe('12:00 AM');
expect(formatTime(6 * 60)).toBe('6:00 AM');
expect(formatTime(8 * 60 + 5)).toBe('8:05 AM');
expect(formatTime(12 * 60)).toBe('12:00 PM');
expect(formatTime(13 * 60 + 30)).toBe('1:30 PM');
expect(formatTime(23 * 60 + 59)).toBe('11:59 PM');
});
});
describe('toHm', () => {
it('formats 24-hour HH:MM strings', () => {
expect(toHm(0)).toBe('00:00');
expect(toHm(6 * 60)).toBe('06:00');
expect(toHm(23 * 60 + 59)).toBe('23:59');
});
});
describe('snapMinutes', () => {
it('rounds to the nearest 15 minutes and clamps to the day', () => {
expect(snapMinutes(0)).toBe(0);
expect(snapMinutes(7)).toBe(0);
expect(snapMinutes(8)).toBe(15);
expect(snapMinutes(370)).toBe(375);
expect(snapMinutes(-10)).toBe(0);
expect(snapMinutes(2000)).toBe(1440);
});
});
describe('isOvernight', () => {
it('is true when the end clock time is before the start', () => {
expect(isOvernight({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(true);
expect(isOvernight({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(true);
});
it('is false for same-day ranges and a full-day range', () => {
expect(isOvernight({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(false);
expect(isOvernight({ startMinutes: 0, endMinutes: 1440 })).toBe(false);
});
});
describe('blockDuration', () => {
it('counts a same-day range directly', () => {
expect(blockDuration({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(120);
});
it('counts an overnight range as wrapping to the next day', () => {
expect(blockDuration({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(720);
});
it('treats an end of midnight as wrapping from the start', () => {
expect(blockDuration({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(120);
});
});
describe('blockSegments', () => {
it('returns a single segment for a same-day range', () => {
expect(blockSegments({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toEqual([[6 * 60, 8 * 60]]);
});
it('splits an overnight range at midnight', () => {
expect(blockSegments({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toEqual([
[21 * 60, 1440],
[0, 9 * 60]
]);
});
it('returns no segments for a zero-length range', () => {
expect(blockSegments({ startMinutes: 360, endMinutes: 360 })).toEqual([]);
});
});
describe('findOverlap', () => {
it('returns null for adjacent ranges', () => {
expect(
findOverlap({ startMinutes: 8 * 60, endMinutes: 9 * 60 }, [sibling(6 * 60, 8 * 60)])
).toBeNull();
});
it('returns the sibling for a partial overlap', () => {
const other = sibling(7 * 60, 9 * 60);
expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other);
});
it('detects an overnight block overlapping an early-morning block', () => {
const other = sibling(8 * 60, 10 * 60);
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
});
it('detects an overnight block overlapping a late-evening block', () => {
const other = sibling(20 * 60, 22 * 60);
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
});
it('allows an overnight block and its exact complement (adjacent at both ends)', () => {
expect(
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(9 * 60, 21 * 60)])
).toBeNull();
});
it('allows an overnight block and a midday block it does not touch', () => {
expect(
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(10 * 60, 20 * 60)])
).toBeNull();
});
});
describe('validateBlock', () => {
it('accepts a valid block with no siblings', () => {
expect(validateBlock(input(), [], { isCreate: true })).toEqual({ ok: true });
});
it('rejects equal start and end times', () => {
const result = validateBlock(input({ endMinutes: 6 * 60 }), []);
expect(result).toEqual({
ok: false,
error: "Start and end time can't be the same."
});
});
it('rejects a time outside the 24-hour range', () => {
expect(validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), [])).toEqual({
ok: false,
error: 'Enter a valid start and end time.'
});
});
it('rejects a negative start time', () => {
expect(validateBlock(input({ startMinutes: -1 }), [])).toEqual({
ok: false,
error: 'Enter a valid start and end time.'
});
});
it('accepts an overnight block', () => {
expect(
validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [], {
isCreate: true
})
).toEqual({ ok: true });
});
it('rejects an overnight block that overlaps another and names it', () => {
const result = validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [
sibling(8 * 60, 10 * 60)
]);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 8:00 AM–10:00 AM block.'
});
});
it('requires at least one proclamation', () => {
expect(validateBlock(input({ proclamationIds: [] }), [])).toEqual({
ok: false,
error: 'Select at least one proclamation.'
});
});
it('enforces the four-block maximum on create', () => {
const siblings = [
sibling(0, 60, 'a'),
sibling(60, 120, 'b'),
sibling(120, 180, 'c'),
sibling(180, 240, 'd')
];
const result = validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings, {
isCreate: true
});
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
});
it('allows adjacent blocks (end exactly equals next start)', () => {
expect(
validateBlock(input({ startMinutes: 8 * 60, endMinutes: 9 * 60 }), [sibling(6 * 60, 8 * 60)])
).toEqual({
ok: true
});
});
it('rejects an exact duplicate range and names the conflicting block', () => {
const result = validateBlock(input(), [sibling(6 * 60, 8 * 60)]);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 6:00 AM–8:00 AM block.'
});
});
it('rejects a partial overlap', () => {
const result = validateBlock(input({ startMinutes: 7 * 60, endMinutes: 9 * 60 }), [
sibling(8 * 60, 10 * 60)
]);
expect(result.ok).toBe(false);
});
it('rejects a block that contains another block', () => {
const result = validateBlock(input({ startMinutes: 5 * 60, endMinutes: 11 * 60 }), [
sibling(6 * 60, 8 * 60)
]);
expect(result.ok).toBe(false);
});
it('does not apply the maximum when updating (isCreate false)', () => {
const siblings = [sibling(0, 60, 'a'), sibling(60, 120, 'b'), sibling(120, 180, 'c')];
expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true);
});
});
+161
View File
@@ -0,0 +1,161 @@
// Isomorphic, dependency-free schedule helpers. Used by the server service
// layer, the v2 serializers and the client-side Block modal (for live
// pre-validation), so it must not import Prisma or any server-only code.
export const MAX_BLOCKS = 4;
export const MINUTES_PER_DAY = 24 * 60;
export const SNAP_MINUTES = 15;
export const MINUTE_STEP = 5;
export type BlockSibling = {
id?: string;
startMinutes: number;
endMinutes: number;
};
export type BlockInput = {
startMinutes: number;
endMinutes: number;
proclamationIds: string[];
/** When true the kiosk enables dynamic audio for the block's duration. */
dynamicAudio?: boolean;
};
export type ValidationResult = { ok: true } | { ok: false; error: string };
function normalizeMinutes(minutes: number): number {
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
}
/** Format minutes-from-midnight as a 12-hour clock label, e.g. `6:00 AM`. */
export function formatTime(minutes: number): string {
const normalized = normalizeMinutes(minutes);
const hours24 = Math.floor(normalized / 60);
const mins = normalized % 60;
const period = hours24 < 12 ? 'AM' : 'PM';
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
return `${hours12}:${String(mins).padStart(2, '0')} ${period}`;
}
/** Format minutes-from-midnight as a 24-hour `HH:MM` string. */
export function toHm(minutes: number): string {
const normalized = normalizeMinutes(minutes);
const hours = Math.floor(normalized / 60);
const mins = normalized % 60;
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
}
/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */
export function snapMinutes(minutes: number): number {
const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;
return Math.max(0, Math.min(MINUTES_PER_DAY, snapped));
}
/** A Block whose end clock time is at or before its start wraps past midnight. */
export function isOvernight(block: { startMinutes: number; endMinutes: number }): boolean {
return block.endMinutes <= block.startMinutes;
}
/** Block length in minutes, counting a wrap past midnight as the next day. */
export function blockDuration(block: { startMinutes: number; endMinutes: number }): number {
if (block.endMinutes > block.startMinutes) {
return block.endMinutes - block.startMinutes;
}
return block.endMinutes - block.startMinutes + MINUTES_PER_DAY;
}
/**
* Split a Block into the linear half-open `[start, end)` segments it occupies
* within the 24h day. An overnight block produces two segments (start→midnight
* and midnight→end); a zero-length block produces none.
*/
export function blockSegments(block: {
startMinutes: number;
endMinutes: number;
}): [number, number][] {
if (block.startMinutes < block.endMinutes) {
return [[block.startMinutes, block.endMinutes]];
}
if (block.startMinutes > block.endMinutes) {
return [
[block.startMinutes, MINUTES_PER_DAY],
[0, block.endMinutes]
];
}
return [];
}
function segmentsIntersect(a: [number, number], b: [number, number]): boolean {
return a[0] < b[1] && b[0] < a[1];
}
/**
* First sibling whose occupied time intersects the input. Works for overnight
* (midnight-crossing) blocks by comparing their linear segments; ranges that
* only touch at an endpoint (e.g. 9PM–9AM vs 9AM–9PM) do not overlap.
*/
export function findOverlap(
input: { startMinutes: number; endMinutes: number },
siblings: BlockSibling[]
): BlockSibling | null {
const inputSegments = blockSegments(input);
for (const sibling of siblings) {
const siblingSegments = blockSegments(sibling);
for (const segment of inputSegments) {
for (const other of siblingSegments) {
if (segmentsIntersect(segment, other)) return sibling;
}
}
}
return null;
}
/**
* Validate a Block against the schedule's other Blocks. `siblings` must exclude
* the Block being edited. `isCreate` enables the 4-block maximum (an update
* never increases the count).
*/
export function validateBlock(
input: BlockInput,
siblings: BlockSibling[],
opts: { isCreate?: boolean } = {}
): ValidationResult {
const { startMinutes, endMinutes, proclamationIds } = input;
if (
!Number.isInteger(startMinutes) ||
!Number.isInteger(endMinutes) ||
startMinutes < 0 ||
startMinutes > MINUTES_PER_DAY ||
endMinutes < 0 ||
endMinutes > MINUTES_PER_DAY
) {
return { ok: false, error: 'Enter a valid start and end time.' };
}
// Equal clock times would be a zero- or full-length block; both are
// ambiguous, so reject them. Overnight ranges (end before start) are valid.
if (startMinutes === endMinutes) {
return { ok: false, error: "Start and end time can't be the same." };
}
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
return { ok: false, error: 'Select at least one proclamation.' };
}
if (opts.isCreate && siblings.length >= MAX_BLOCKS) {
return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks.` };
}
const overlap = findOverlap(input, siblings);
if (overlap) {
return {
ok: false,
error: `This overlaps with your ${formatTime(overlap.startMinutes)}–${formatTime(
overlap.endMinutes
)} block.`
};
}
return { ok: true };
}
+200
View File
@@ -0,0 +1,200 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn() },
block: {
findMany: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule';
const scheduleFindUnique = prisma.schedule.findUnique as ReturnType<typeof vi.fn>;
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
const blockFindFirst = prisma.block.findFirst as ReturnType<typeof vi.fn>;
const blockCreate = prisma.block.create as ReturnType<typeof vi.fn>;
const blockUpdate = prisma.block.update as ReturnType<typeof vi.fn>;
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
const validInput = {
startMinutes: 6 * 60,
endMinutes: 8 * 60,
proclamationIds: ['p1']
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('getOrCreateSchedule', () => {
it('returns the existing schedule', async () => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
const result = await getOrCreateSchedule('u1');
expect(result).toEqual({ id: 's1', userId: 'u1' });
expect(scheduleCreate).not.toHaveBeenCalled();
});
it('creates the singleton schedule when missing', async () => {
scheduleFindUnique.mockResolvedValue(null);
scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' });
await getOrCreateSchedule('u1');
expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } });
});
});
describe('listBlocks', () => {
it('scopes to the user and includes proclamations with frequencies', async () => {
blockFindMany.mockResolvedValue([]);
await listBlocks('u1');
expect(blockFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { schedule: { userId: 'u1' } },
orderBy: { startMinutes: 'asc' },
include: expect.objectContaining({ proclamations: expect.anything() })
})
);
});
});
describe('createBlock', () => {
beforeEach(() => {
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
});
it('creates a block with deduped proclamations in order', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
const result = await createBlock('u1', {
...validInput,
proclamationIds: ['p1', 'p1', 'p2']
});
expect(result).toEqual({ ok: true, id: 'new-block' });
expect(blockCreate).toHaveBeenCalledWith({
data: {
scheduleId: 's1',
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: false,
proclamations: {
create: [
{ proclamationId: 'p1', sortOrder: 0 },
{ proclamationId: 'p2', sortOrder: 1 }
]
}
}
});
});
it('persists the dynamic audio flag', async () => {
blockFindMany.mockResolvedValue([]);
blockCreate.mockResolvedValue({ id: 'new-block' });
await createBlock('u1', { ...validInput, dynamicAudio: true });
expect(blockCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ dynamicAudio: true })
})
);
});
it('rejects a fifth block without creating', async () => {
blockFindMany.mockResolvedValue([
{ id: 'a', startMinutes: 0, endMinutes: 60 },
{ id: 'b', startMinutes: 60, endMinutes: 120 },
{ id: 'c', startMinutes: 120, endMinutes: 180 },
{ id: 'd', startMinutes: 180, endMinutes: 240 }
]);
const result = await createBlock('u1', validInput);
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
expect(blockCreate).not.toHaveBeenCalled();
});
it('rejects an overlapping block with a specific message', async () => {
blockFindMany.mockResolvedValue([{ id: 'a', startMinutes: 7 * 60, endMinutes: 9 * 60 }]);
const result = await createBlock('u1', validInput);
expect(result).toEqual({
ok: false,
error: 'This overlaps with your 7:00 AM–9:00 AM block.'
});
expect(blockCreate).not.toHaveBeenCalled();
});
});
describe('updateBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await updateBlock('u1', 'missing', validInput);
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockUpdate).not.toHaveBeenCalled();
});
it('replaces proclamations and excludes itself from overlap checks', async () => {
blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' });
blockFindMany.mockResolvedValue([]);
blockUpdate.mockResolvedValue({ id: 'b1' });
const result = await updateBlock('u1', 'b1', validInput);
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockFindMany).toHaveBeenCalledWith({
where: { scheduleId: 's1', id: { not: 'b1' } },
select: { id: true, startMinutes: true, endMinutes: true }
});
expect(blockUpdate).toHaveBeenCalledWith({
where: { id: 'b1' },
data: {
startMinutes: validInput.startMinutes,
endMinutes: validInput.endMinutes,
dynamicAudio: false,
proclamations: {
deleteMany: {},
create: [{ proclamationId: 'p1', sortOrder: 0 }]
}
}
});
});
});
describe('deleteBlock', () => {
it('returns not found when the block does not belong to the user', async () => {
blockFindFirst.mockResolvedValue(null);
const result = await deleteBlock('u1', 'missing');
expect(result).toEqual({ ok: false, error: 'Block not found.' });
expect(blockDelete).not.toHaveBeenCalled();
});
it('deletes an owned block', async () => {
blockFindFirst.mockResolvedValue({ id: 'b1' });
blockDelete.mockResolvedValue({ id: 'b1' });
const result = await deleteBlock('u1', 'b1');
expect(result).toEqual({ ok: true, id: 'b1' });
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
});
});
+131
View File
@@ -0,0 +1,131 @@
// Schedule service layer. Owns the database orchestration and enforcement of
// the Block business rules (at most 4 per user, at least one Proclamation,
// no overnight ranges, no overlaps). Pure validation lives in
// `$lib/schedule-utils` so the client modal can reuse it.
import { prisma } from '$lib/db';
import { validateBlock, type BlockInput } from '$lib/schedule-utils';
export {
MAX_BLOCKS,
MINUTES_PER_DAY,
SNAP_MINUTES,
MINUTE_STEP,
formatTime,
toHm,
snapMinutes,
findOverlap,
validateBlock
} from '$lib/schedule-utils';
export type { BlockInput, BlockSibling, ValidationResult } from '$lib/schedule-utils';
export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string };
function uniqueIds(ids: string[]): string[] {
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))];
}
/** Ensure the user has a Schedule row, creating the singleton on first use. */
export async function getOrCreateSchedule(userId: string) {
const existing = await prisma.schedule.findUnique({ where: { userId } });
if (existing) return existing;
return prisma.schedule.create({ data: { userId } });
}
/**
* The user's Blocks with their selected Proclamations (and frequencies for
* canonical-id mapping), ordered by start time.
*/
export async function listBlocks(userId: string) {
return prisma.block.findMany({
where: { schedule: { userId } },
orderBy: { startMinutes: 'asc' },
include: {
proclamations: {
orderBy: { sortOrder: 'asc' },
include: {
proclamation: { include: { solfeggioFrequency: true } }
}
}
}
});
}
export async function createBlock(userId: string, input: BlockInput): Promise<BlockMutationResult> {
const schedule = await getOrCreateSchedule(userId);
const siblings = await prisma.block.findMany({
where: { scheduleId: schedule.id },
select: { id: true, startMinutes: true, endMinutes: true }
});
const validation = validateBlock(input, siblings, { isCreate: true });
if (!validation.ok) return validation;
const proclamationIds = uniqueIds(input.proclamationIds);
const block = await prisma.block.create({
data: {
scheduleId: schedule.id,
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
dynamicAudio: input.dynamicAudio === true,
proclamations: {
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
return { ok: true, id: block.id };
}
export async function updateBlock(
userId: string,
blockId: string,
input: BlockInput
): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { userId } },
select: { id: true, scheduleId: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
const siblings = await prisma.block.findMany({
where: { scheduleId: block.scheduleId, id: { not: blockId } },
select: { id: true, startMinutes: true, endMinutes: true }
});
const validation = validateBlock(input, siblings);
if (!validation.ok) return validation;
const proclamationIds = uniqueIds(input.proclamationIds);
await prisma.block.update({
where: { id: blockId },
data: {
startMinutes: input.startMinutes,
endMinutes: input.endMinutes,
dynamicAudio: input.dynamicAudio === true,
proclamations: {
deleteMany: {},
create: proclamationIds.map((proclamationId, index) => ({
proclamationId,
sortOrder: index
}))
}
}
});
return { ok: true, id: blockId };
}
export async function deleteBlock(userId: string, blockId: string): Promise<BlockMutationResult> {
const block = await prisma.block.findFirst({
where: { id: blockId, schedule: { userId } },
select: { id: true }
});
if (!block) return { ok: false, error: 'Block not found.' };
await prisma.block.delete({ where: { id: blockId } });
return { ok: true, id: blockId };
}
+45
View File
@@ -1,3 +1,5 @@
import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils';
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()` // Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these // map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
// canonical ids (1-9), never the portal DB autoincrement ids, because // canonical ids (1-9), never the portal DB autoincrement ids, because
@@ -138,3 +140,46 @@ export function serializeColorPalette(palette: {
}): V2ColorPalette { }): V2ColorPalette {
return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors }; return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors };
} }
export type V2ScheduleBlock = {
id: string;
startTime: string;
startMinutes: number;
endTime: string;
endMinutes: number;
overnight: boolean;
durationMinutes: number;
dynamicAudio: boolean;
proclamationIds: string[];
};
export type V2Schedule = {
email: string;
blocks: V2ScheduleBlock[];
};
/** Build a schedule Block for the kiosk. Only the selected Proclamation IDs are
* returned; the kiosk fetches proclamation content separately. `overnight`
* marks a range that crosses midnight (end clock time before start), and
* `durationMinutes` counts the wrap as the next day. */
export function serializeScheduleBlock(
block: {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio?: boolean;
},
proclamationIds: string[]
): V2ScheduleBlock {
return {
id: block.id,
startTime: toHm(block.startMinutes),
startMinutes: block.startMinutes,
endTime: toHm(block.endMinutes),
endMinutes: block.endMinutes,
overnight: isOvernight(block),
durationMinutes: blockDuration(block),
dynamicAudio: block.dynamicAudio === true,
proclamationIds
};
}
@@ -0,0 +1,91 @@
import type { Actions, PageServerLoad } from './$types';
import { error, fail } from '@sveltejs/kit';
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks, updateBlock } from '$lib/server/schedule';
function parseInput(formData: FormData) {
const startMinutes = Number(formData.get('startMinutes'));
const endMinutes = Number(formData.get('endMinutes'));
let proclamationIds: string[] = [];
const raw = formData.get('proclamationIds');
if (typeof raw === 'string' && raw.length > 0) {
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
proclamationIds = parsed.filter((id): id is string => typeof id === 'string');
}
} catch {
proclamationIds = [];
}
}
const dynamicAudio = formData.get('dynamicAudio') === 'true';
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
}
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const [blocks, categories, groups, proclamations] = await Promise.all([
listBlocks(locals.user.id),
prisma.proclamationCategory.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
}),
prisma.proclamationGroup.findMany({
select: { id: true, name: true },
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
}),
// Content is deliberately never selected: the portal only surfaces
// title/category/group.
prisma.proclamation.findMany({
select: { id: true, name: true, categoryId: true, groupId: true },
orderBy: { name: 'asc' }
})
]);
const blockViews = blocks.map((block) => ({
id: block.id,
startMinutes: block.startMinutes,
endMinutes: block.endMinutes,
dynamicAudio: block.dynamicAudio,
proclamations: block.proclamations.map((entry) => ({
id: entry.proclamation.id,
name: entry.proclamation.name,
categoryId: entry.proclamation.categoryId,
groupId: entry.proclamation.groupId
}))
}));
return { blocks: blockViews, categories, groups, proclamations };
};
export const actions: Actions = {
createBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const input = parseInput(await request.formData());
const result = await createBlock(locals.user.id, input);
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
updateBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const formData = await request.formData();
const id = formData.get('id')?.toString();
if (!id) return fail(400, { error: 'Block not found.' });
const result = await updateBlock(locals.user.id, id, parseInput(formData));
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
},
deleteBlock: async ({ request, locals }) => {
if (!locals.user?.id) throw error(403, 'not authorized');
const id = (await request.formData()).get('id')?.toString();
if (!id) return fail(400, { error: 'Block not found.' });
const result = await deleteBlock(locals.user.id, id);
if (!result.ok) return fail(400, { error: result.error });
return { success: true };
}
};
@@ -0,0 +1,100 @@
<script lang="ts">
import type { PageProps } from './$types';
import DaySchedule from '$lib/components/schedule/DaySchedule.svelte';
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
let { data }: PageProps = $props();
let modalOpen = $state(false);
let modalMode = $state<'create' | 'edit'>('create');
let editingId = $state<string | undefined>(undefined);
let initialStart = $state(9 * 60);
let initialEnd = $state(10 * 60);
let initialIds = $state<string[]>([]);
let initialDynamicAudio = $state(false);
let successMessage = $state('');
let closeTimer: ReturnType<typeof setTimeout> | undefined;
function openCreate(startMinutes: number, endMinutes: number) {
modalMode = 'create';
editingId = undefined;
initialStart = startMinutes;
initialEnd = endMinutes;
initialIds = [];
initialDynamicAudio = false;
modalOpen = true;
}
function openEdit(block: {
id: string;
startMinutes: number;
endMinutes: number;
dynamicAudio: boolean;
proclamations: { id: string }[];
}) {
modalMode = 'edit';
editingId = block.id;
initialStart = block.startMinutes;
initialEnd = block.endMinutes;
initialIds = block.proclamations.map((proclamation) => proclamation.id);
initialDynamicAudio = block.dynamicAudio;
modalOpen = true;
}
function closeModal() {
modalOpen = false;
}
function handleSaved() {
modalOpen = false;
successMessage = modalMode === 'edit' ? 'Block saved.' : 'Block created.';
clearTimeout(closeTimer);
closeTimer = setTimeout(() => (successMessage = ''), 1500);
}
</script>
<div>
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl dark:text-slate-100">
Schedule
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
Build your daily listening schedule. Up to 4 blocks per day.
</p>
</div>
<span
class="rounded-full bg-slate-100 px-3 py-1 text-sm font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{data.blocks.length} of 4 blocks
</span>
</div>
{#if successMessage}
<div
class="mt-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-900 dark:bg-green-950/50 dark:text-green-300"
>
{successMessage}
</div>
{/if}
<div class="mt-6">
<DaySchedule blocks={data.blocks} onCreate={openCreate} onEdit={openEdit} />
</div>
<BlockModal
open={modalOpen}
mode={modalMode}
blockId={editingId}
initialStartMinutes={initialStart}
initialEndMinutes={initialEnd}
initialProclamationIds={initialIds}
{initialDynamicAudio}
categories={data.categories}
groups={data.groups}
proclamations={data.proclamations}
existingBlocks={data.blocks}
onClose={closeModal}
onSaved={handleSaved}
/>
</div>
@@ -0,0 +1,181 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isActionFailure } from '@sveltejs/kit';
import { createFormRequest, createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
proclamationCategory: { findMany: vi.fn() },
proclamationGroup: { findMany: vi.fn() },
proclamation: { findMany: vi.fn() }
}
}));
vi.mock('$lib/server/schedule', () => ({
listBlocks: vi.fn(),
createBlock: vi.fn(),
updateBlock: vi.fn(),
deleteBlock: vi.fn()
}));
import { prisma } from '$lib/db';
import { createBlock, deleteBlock, listBlocks } from '$lib/server/schedule';
import { actions, load } from './+page.server';
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
const listBlocksMock = listBlocks as ReturnType<typeof vi.fn>;
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
const locals = createLocals(mockUser() as never);
beforeEach(() => {
vi.clearAllMocks();
(prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 2, name: 'Physical' }
]);
(prisma.proclamationGroup.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 1, name: 'General' }
]);
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
listBlocksMock.mockResolvedValue([]);
});
describe('schedule load', () => {
it('rejects unauthenticated requests', async () => {
await expect(load({ locals: createLocals() } as never)).rejects.toMatchObject({
status: 403
});
});
it('never selects proclamation content', async () => {
await load({ locals } as never);
expect(procFindMany).toHaveBeenCalledWith(
expect.objectContaining({
select: { id: true, name: true, categoryId: true, groupId: true }
})
);
});
it('maps blocks to title/category/group only', async () => {
listBlocksMock.mockResolvedValue([
{
id: 'b1',
startMinutes: 360,
endMinutes: 480,
dynamicAudio: true,
proclamations: [
{
proclamation: {
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1,
content: '<p>secret</p>'
}
}
]
}
]);
const result = (await load({ locals } as never)) as {
blocks: {
id: string;
dynamicAudio: boolean;
proclamations: Record<string, unknown>[];
}[];
};
expect(result.blocks[0].dynamicAudio).toBe(true);
expect(result.blocks[0].proclamations[0]).toEqual({
id: 'p1',
name: 'Peace',
categoryId: 2,
groupId: 1
});
expect(Object.keys(result.blocks[0].proclamations[0])).not.toContain('content');
});
});
describe('schedule actions', () => {
it('createBlock rejects unauthenticated requests', async () => {
await expect(
actions.createBlock!({
request: createFormRequest({}),
locals: createLocals()
} as never)
).rejects.toMatchObject({ status: 403 });
});
it('createBlock surfaces the service error', async () => {
createBlockMock.mockResolvedValue({ ok: false, error: 'Select at least one proclamation.' });
const result = await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: '[]'
}),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.status).toBe(400);
expect(result.data).toEqual({ error: 'Select at least one proclamation.' });
}
});
it('createBlock parses the payload and succeeds', async () => {
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
const result = await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1', 'p2']),
dynamicAudio: 'true'
}),
locals
} as never);
expect(result).toEqual({ success: true });
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
startMinutes: 360,
endMinutes: 480,
proclamationIds: ['p1', 'p2'],
dynamicAudio: true
});
});
it('createBlock defaults dynamic audio to false when the field is absent', async () => {
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
await actions.createBlock!({
request: createFormRequest({
startMinutes: '360',
endMinutes: '480',
proclamationIds: JSON.stringify(['p1'])
}),
locals
} as never);
expect(createBlockMock).toHaveBeenCalledWith(
'user-1',
expect.objectContaining({ dynamicAudio: false })
);
});
it('deleteBlock returns not found without an id', async () => {
const result = await actions.deleteBlock!({
request: createFormRequest({}),
locals
} as never);
expect(isActionFailure(result)).toBe(true);
if (isActionFailure(result)) {
expect(result.data).toEqual({ error: 'Block not found.' });
}
expect(deleteBlockMock).not.toHaveBeenCalled();
});
});
+13
View File
@@ -129,6 +129,12 @@
> >
Dashboard Dashboard
</a> </a>
<a
href={resolve('/schedule')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/schedule')}`}
>
Schedule
</a>
<a <a
href={resolve('/profile')} href={resolve('/profile')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/profile')}`} class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/profile')}`}
@@ -383,6 +389,13 @@
> >
Dashboard Dashboard
</a> </a>
<a
href={resolve('/schedule')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/schedule')}`}
>
Schedule
</a>
<a <a
href={resolve('/profile')} href={resolve('/profile')}
onclick={closeMenu} onclick={closeMenu}
+63
View File
@@ -0,0 +1,63 @@
import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { prisma } from '$lib/db';
import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers';
import { rateLimit } from '$lib/server/v2-rate-limit';
import { listBlocks } from '$lib/server/schedule';
import { serializeScheduleBlock } from '$lib/server/v2-serializers';
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/**
* Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with
* `email` + `licenseKey` query parameters and validates only that the pair
* belong together — license active/entitlement is deliberately not checked
* here, since an invalid license would already lock the kiosk out.
*/
export const GET: RequestHandler = async ({ request, url }) => {
const ip = clientIp(request);
const rl = rateLimit({ key: `schedule:${ip}`, limit: 10, windowMs: 60_000 });
if (!rl.ok) {
return rateLimited(rl.retryAfterMs);
}
const email = (url.searchParams.get('email') ?? '').trim().toLowerCase();
const licenseKey = (
url.searchParams.get('licenseKey') ??
url.searchParams.get('key') ??
''
).trim();
if (!email || !licenseKey || email.length > 254 || !EMAIL_RE.test(email)) {
throw error(400, 'not-authorized');
}
const storedKey = await prisma.licenseKey.findUnique({ where: { key: licenseKey } });
if (!storedKey) {
throw error(400, 'not-authorized');
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
throw error(400, 'not-authorized');
}
// Licenses created before accounts were linked may carry only an email.
const belongs =
storedKey.userId === user.id ||
(storedKey.userId === null && storedKey.email?.toLowerCase() === email);
if (!belongs) {
throw error(400, "this key doesn't appear to belong to you");
}
const blocks = await listBlocks(user.id);
const serialized = blocks.map((block) =>
serializeScheduleBlock(
block,
block.proclamations.map((entry) => entry.proclamation.id)
)
);
return jsonOk({ email, blocks: serialized });
};
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
vi.mock('$lib/db', () => ({
prisma: {
schedule: { findUnique: vi.fn(), create: vi.fn() },
block: { findMany: vi.fn() },
licenseKey: { findUnique: vi.fn() },
user: { findUnique: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { GET } from './+server';
const licenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
function call(params: Record<string, string>) {
const url = new URL('http://localhost/api/v2/schedule');
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
const request = new Request(url);
return GET({ request, url } as Parameters<typeof GET>[0]);
}
const KEY = 'AAAA-BBBB-CCCC-DDDD';
beforeEach(() => {
vi.clearAllMocks();
_clearRateLimits();
blockFindMany.mockResolvedValue([]);
});
describe('GET /api/v2/schedule auth', () => {
it('rejects a request missing parameters', async () => {
await expect(call({})).rejects.toMatchObject({
status: 400,
body: { message: 'not-authorized' }
});
});
it('rejects an unknown license key', async () => {
licenseKeyFindUnique.mockResolvedValue(null);
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
status: 400,
body: { message: 'not-authorized' }
});
});
it('rejects an unknown user', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
userFindUnique.mockResolvedValue(null);
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
status: 400,
body: { message: 'not-authorized' }
});
});
it('rejects a key that does not belong to the email account', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'someone-else', email: null });
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
status: 400,
body: { message: "this key doesn't appear to belong to you" }
});
});
it('rate limits after ten requests from the same IP', async () => {
licenseKeyFindUnique.mockResolvedValue(null);
for (let i = 0; i < 10; i++) {
try {
await call({});
} catch {
// Expected: each early call rejects with a 400.
}
}
const response = await call({});
expect(response.status).toBe(429);
});
});
describe('GET /api/v2/schedule success', () => {
it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => {
licenseKeyFindUnique.mockResolvedValue({
key: KEY,
userId: 'user-1',
email: null,
active: false
});
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
blockFindMany.mockResolvedValue([
{
id: 'block-1',
startMinutes: 21 * 60,
endMinutes: 9 * 60,
dynamicAudio: true,
proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }]
}
]);
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({
email: 'jane@example.com',
blocks: [
{
id: 'block-1',
startTime: '21:00',
startMinutes: 1260,
endTime: '09:00',
endMinutes: 540,
overnight: true,
durationMinutes: 720,
dynamicAudio: true,
proclamationIds: ['p1', 'p2']
}
]
});
});
it('returns IDs for every selected proclamation regardless of frequency', async () => {
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
blockFindMany.mockResolvedValue([
{
id: 'block-1',
startMinutes: 0,
endMinutes: 60,
proclamations: [{ proclamation: { id: 'p1' } }]
}
]);
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
const body = (await response.json()) as {
blocks: { proclamationIds: string[]; overnight: boolean; durationMinutes: number }[];
};
expect(body.blocks[0]).toEqual({
id: 'block-1',
startTime: '00:00',
startMinutes: 0,
endTime: '01:00',
endMinutes: 60,
overnight: false,
durationMinutes: 60,
dynamicAudio: false,
proclamationIds: ['p1']
});
});
it('accepts the legacy `key` query alias and an email-only license', async () => {
licenseKeyFindUnique.mockResolvedValue({
key: KEY,
userId: null,
email: 'jane@example.com'
});
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
const response = await call({ email: 'jane@example.com', key: KEY });
expect(response.status).toBe(200);
await expect(response.json()).resolves.toEqual({ email: 'jane@example.com', blocks: [] });
});
});