Merge branch 'dev'
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -246,6 +246,7 @@ export type UserWhereInput = {
|
|||||||
profiles?: Prisma.ProfileListRelationFilter
|
profiles?: Prisma.ProfileListRelationFilter
|
||||||
licenseKeys?: Prisma.LicenseKeyListRelationFilter
|
licenseKeys?: Prisma.LicenseKeyListRelationFilter
|
||||||
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
||||||
|
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserOrderByWithRelationInput = {
|
export type UserOrderByWithRelationInput = {
|
||||||
@@ -263,6 +264,7 @@ export type UserOrderByWithRelationInput = {
|
|||||||
profiles?: Prisma.ProfileOrderByRelationAggregateInput
|
profiles?: Prisma.ProfileOrderByRelationAggregateInput
|
||||||
licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput
|
licenseKeys?: Prisma.LicenseKeyOrderByRelationAggregateInput
|
||||||
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
|
userProclamations?: Prisma.UserProclamationsOrderByRelationAggregateInput
|
||||||
|
schedule?: Prisma.ScheduleOrderByWithRelationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||||
@@ -283,6 +285,7 @@ export type UserWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
profiles?: Prisma.ProfileListRelationFilter
|
profiles?: Prisma.ProfileListRelationFilter
|
||||||
licenseKeys?: Prisma.LicenseKeyListRelationFilter
|
licenseKeys?: Prisma.LicenseKeyListRelationFilter
|
||||||
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
userProclamations?: Prisma.UserProclamationsListRelationFilter
|
||||||
|
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
|
||||||
}, "id" | "email">
|
}, "id" | "email">
|
||||||
|
|
||||||
export type UserOrderByWithAggregationInput = {
|
export type UserOrderByWithAggregationInput = {
|
||||||
@@ -329,6 +332,7 @@ export type UserCreateInput = {
|
|||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateInput = {
|
export type UserUncheckedCreateInput = {
|
||||||
@@ -345,6 +349,7 @@ export type UserUncheckedCreateInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUpdateInput = {
|
export type UserUpdateInput = {
|
||||||
@@ -361,6 +366,7 @@ export type UserUpdateInput = {
|
|||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateInput = {
|
export type UserUncheckedUpdateInput = {
|
||||||
@@ -377,6 +383,7 @@ export type UserUncheckedUpdateInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateManyInput = {
|
export type UserCreateManyInput = {
|
||||||
@@ -612,6 +619,20 @@ export type UserUpdateOneWithoutLicenseKeysNestedInput = {
|
|||||||
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput>
|
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutLicenseKeysInput, Prisma.UserUpdateWithoutLicenseKeysInput>, Prisma.UserUncheckedUpdateWithoutLicenseKeysInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UserCreateNestedOneWithoutScheduleInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
|
||||||
|
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
|
||||||
|
connect?: Prisma.UserWhereUniqueInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUpdateOneRequiredWithoutScheduleNestedInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
|
||||||
|
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
|
||||||
|
upsert?: Prisma.UserUpsertWithoutScheduleInput
|
||||||
|
connect?: Prisma.UserWhereUniqueInput
|
||||||
|
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutScheduleInput, Prisma.UserUpdateWithoutScheduleInput>, Prisma.UserUncheckedUpdateWithoutScheduleInput>
|
||||||
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutProfilesInput = {
|
export type UserCreateWithoutProfilesInput = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -625,6 +646,7 @@ export type UserCreateWithoutProfilesInput = {
|
|||||||
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutProfilesInput = {
|
export type UserUncheckedCreateWithoutProfilesInput = {
|
||||||
@@ -640,6 +662,7 @@ export type UserUncheckedCreateWithoutProfilesInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutProfilesInput = {
|
export type UserCreateOrConnectWithoutProfilesInput = {
|
||||||
@@ -671,6 +694,7 @@ export type UserUpdateWithoutProfilesInput = {
|
|||||||
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutProfilesInput = {
|
export type UserUncheckedUpdateWithoutProfilesInput = {
|
||||||
@@ -686,6 +710,7 @@ export type UserUncheckedUpdateWithoutProfilesInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutSessionsInput = {
|
export type UserCreateWithoutSessionsInput = {
|
||||||
@@ -701,6 +726,7 @@ export type UserCreateWithoutSessionsInput = {
|
|||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutSessionsInput = {
|
export type UserUncheckedCreateWithoutSessionsInput = {
|
||||||
@@ -716,6 +742,7 @@ export type UserUncheckedCreateWithoutSessionsInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutSessionsInput = {
|
export type UserCreateOrConnectWithoutSessionsInput = {
|
||||||
@@ -747,6 +774,7 @@ export type UserUpdateWithoutSessionsInput = {
|
|||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutSessionsInput = {
|
export type UserUncheckedUpdateWithoutSessionsInput = {
|
||||||
@@ -762,6 +790,7 @@ export type UserUncheckedUpdateWithoutSessionsInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutAccountsInput = {
|
export type UserCreateWithoutAccountsInput = {
|
||||||
@@ -777,6 +806,7 @@ export type UserCreateWithoutAccountsInput = {
|
|||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutAccountsInput = {
|
export type UserUncheckedCreateWithoutAccountsInput = {
|
||||||
@@ -792,6 +822,7 @@ export type UserUncheckedCreateWithoutAccountsInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutAccountsInput = {
|
export type UserCreateOrConnectWithoutAccountsInput = {
|
||||||
@@ -823,6 +854,7 @@ export type UserUpdateWithoutAccountsInput = {
|
|||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutAccountsInput = {
|
export type UserUncheckedUpdateWithoutAccountsInput = {
|
||||||
@@ -838,6 +870,7 @@ export type UserUncheckedUpdateWithoutAccountsInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutRoleInput = {
|
export type UserCreateWithoutRoleInput = {
|
||||||
@@ -853,6 +886,7 @@ export type UserCreateWithoutRoleInput = {
|
|||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutRoleInput = {
|
export type UserUncheckedCreateWithoutRoleInput = {
|
||||||
@@ -868,6 +902,7 @@ export type UserUncheckedCreateWithoutRoleInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutRoleInput = {
|
export type UserCreateOrConnectWithoutRoleInput = {
|
||||||
@@ -923,6 +958,7 @@ export type UserCreateWithoutUserProclamationsInput = {
|
|||||||
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutUserProclamationsInput = {
|
export type UserUncheckedCreateWithoutUserProclamationsInput = {
|
||||||
@@ -938,6 +974,7 @@ export type UserUncheckedCreateWithoutUserProclamationsInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutUserProclamationsInput = {
|
export type UserCreateOrConnectWithoutUserProclamationsInput = {
|
||||||
@@ -969,6 +1006,7 @@ export type UserUpdateWithoutUserProclamationsInput = {
|
|||||||
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutUserProclamationsInput = {
|
export type UserUncheckedUpdateWithoutUserProclamationsInput = {
|
||||||
@@ -984,6 +1022,7 @@ export type UserUncheckedUpdateWithoutUserProclamationsInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutLicenseKeysInput = {
|
export type UserCreateWithoutLicenseKeysInput = {
|
||||||
@@ -999,6 +1038,7 @@ export type UserCreateWithoutLicenseKeysInput = {
|
|||||||
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
||||||
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutLicenseKeysInput = {
|
export type UserUncheckedCreateWithoutLicenseKeysInput = {
|
||||||
@@ -1014,6 +1054,7 @@ export type UserUncheckedCreateWithoutLicenseKeysInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
||||||
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutLicenseKeysInput = {
|
export type UserCreateOrConnectWithoutLicenseKeysInput = {
|
||||||
@@ -1045,6 +1086,7 @@ export type UserUpdateWithoutLicenseKeysInput = {
|
|||||||
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutLicenseKeysInput = {
|
export type UserUncheckedUpdateWithoutLicenseKeysInput = {
|
||||||
@@ -1060,6 +1102,87 @@ export type UserUncheckedUpdateWithoutLicenseKeysInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserCreateWithoutScheduleInput = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
emailVerified?: boolean
|
||||||
|
image?: string | null
|
||||||
|
createdAt?: Date | string
|
||||||
|
updatedAt?: Date | string
|
||||||
|
role?: Prisma.UserRoleCreateNestedOneWithoutUsersInput
|
||||||
|
sessions?: Prisma.SessionCreateNestedManyWithoutUserInput
|
||||||
|
accounts?: Prisma.AccountCreateNestedManyWithoutUserInput
|
||||||
|
profiles?: Prisma.ProfileCreateNestedManyWithoutUserInput
|
||||||
|
licenseKeys?: Prisma.LicenseKeyCreateNestedManyWithoutUserInput
|
||||||
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUncheckedCreateWithoutScheduleInput = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
emailVerified?: boolean
|
||||||
|
image?: string | null
|
||||||
|
createdAt?: Date | string
|
||||||
|
updatedAt?: Date | string
|
||||||
|
roleId?: number | null
|
||||||
|
sessions?: Prisma.SessionUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
accounts?: Prisma.AccountUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
profiles?: Prisma.ProfileUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
licenseKeys?: Prisma.LicenseKeyUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserCreateOrConnectWithoutScheduleInput = {
|
||||||
|
where: Prisma.UserWhereUniqueInput
|
||||||
|
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUpsertWithoutScheduleInput = {
|
||||||
|
update: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
|
||||||
|
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
|
||||||
|
where?: Prisma.UserWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUpdateToOneWithWhereWithoutScheduleInput = {
|
||||||
|
where?: Prisma.UserWhereInput
|
||||||
|
data: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUpdateWithoutScheduleInput = {
|
||||||
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
role?: Prisma.UserRoleUpdateOneWithoutUsersNestedInput
|
||||||
|
sessions?: Prisma.SessionUpdateManyWithoutUserNestedInput
|
||||||
|
accounts?: Prisma.AccountUpdateManyWithoutUserNestedInput
|
||||||
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UserUncheckedUpdateWithoutScheduleInput = {
|
||||||
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
emailVerified?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
image?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
roleId?: Prisma.NullableIntFieldUpdateOperationsInput | number | null
|
||||||
|
sessions?: Prisma.SessionUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateManyRoleInput = {
|
export type UserCreateManyRoleInput = {
|
||||||
@@ -1085,6 +1208,7 @@ export type UserUpdateWithoutRoleInput = {
|
|||||||
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutRoleInput = {
|
export type UserUncheckedUpdateWithoutRoleInput = {
|
||||||
@@ -1100,6 +1224,7 @@ export type UserUncheckedUpdateWithoutRoleInput = {
|
|||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
licenseKeys?: Prisma.LicenseKeyUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateManyWithoutRoleInput = {
|
export type UserUncheckedUpdateManyWithoutRoleInput = {
|
||||||
@@ -1194,6 +1319,7 @@ export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
|
|||||||
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
|
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
|
||||||
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
|
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
|
||||||
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
|
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
|
||||||
|
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["user"]>
|
}, ExtArgs["result"]["user"]>
|
||||||
|
|
||||||
@@ -1240,6 +1366,7 @@ export type UserInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||||||
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
|
profiles?: boolean | Prisma.User$profilesArgs<ExtArgs>
|
||||||
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
|
licenseKeys?: boolean | Prisma.User$licenseKeysArgs<ExtArgs>
|
||||||
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
|
userProclamations?: boolean | Prisma.User$userProclamationsArgs<ExtArgs>
|
||||||
|
schedule?: boolean | Prisma.User$scheduleArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type UserIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
@@ -1258,6 +1385,7 @@ export type $UserPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||||||
profiles: Prisma.$ProfilePayload<ExtArgs>[]
|
profiles: Prisma.$ProfilePayload<ExtArgs>[]
|
||||||
licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[]
|
licenseKeys: Prisma.$LicenseKeyPayload<ExtArgs>[]
|
||||||
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
|
userProclamations: Prisma.$UserProclamationsPayload<ExtArgs>[]
|
||||||
|
schedule: Prisma.$SchedulePayload<ExtArgs> | null
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
id: string
|
||||||
@@ -1668,6 +1796,7 @@ export interface Prisma__UserClient<T, Null = never, ExtArgs extends runtime.Typ
|
|||||||
profiles<T extends Prisma.User$profilesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profilesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProfilePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
profiles<T extends Prisma.User$profilesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$profilesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$ProfilePayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||||
licenseKeys<T extends Prisma.User$licenseKeysArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$licenseKeysArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
licenseKeys<T extends Prisma.User$licenseKeysArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$licenseKeysArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||||
userProclamations<T extends Prisma.User$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
userProclamations<T extends Prisma.User$userProclamationsArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$userProclamationsArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$UserProclamationsPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
||||||
|
schedule<T extends Prisma.User$scheduleArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$scheduleArgs<ExtArgs>>): Prisma.Prisma__ScheduleClient<runtime.Types.Result.GetResult<Prisma.$SchedulePayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
|
||||||
/**
|
/**
|
||||||
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
* Attaches callbacks for the resolution and/or rejection of the Promise.
|
||||||
* @param onfulfilled The callback to execute when the Promise is resolved.
|
* @param onfulfilled The callback to execute when the Promise is resolved.
|
||||||
@@ -2244,6 +2373,25 @@ export type User$userProclamationsArgs<ExtArgs extends runtime.Types.Extensions.
|
|||||||
distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[]
|
distinct?: Prisma.UserProclamationsScalarFieldEnum | Prisma.UserProclamationsScalarFieldEnum[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User.schedule
|
||||||
|
*/
|
||||||
|
export type User$scheduleArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
|
/**
|
||||||
|
* Select specific fields to fetch from the Schedule
|
||||||
|
*/
|
||||||
|
select?: Prisma.ScheduleSelect<ExtArgs> | null
|
||||||
|
/**
|
||||||
|
* Omit specific fields from the Schedule
|
||||||
|
*/
|
||||||
|
omit?: Prisma.ScheduleOmit<ExtArgs> | null
|
||||||
|
/**
|
||||||
|
* Choose, which related nodes to fetch as well
|
||||||
|
*/
|
||||||
|
include?: Prisma.ScheduleInclude<ExtArgs> | null
|
||||||
|
where?: Prisma.ScheduleWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User without action
|
* User without action
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
blockDuration,
|
||||||
|
blockSegments,
|
||||||
|
findOverlap,
|
||||||
|
formatTime,
|
||||||
|
isOvernight,
|
||||||
|
snapMinutes,
|
||||||
|
toHm,
|
||||||
|
validateBlock,
|
||||||
|
type BlockInput,
|
||||||
|
type BlockSibling
|
||||||
|
} from './schedule-utils';
|
||||||
|
|
||||||
|
const input = (overrides: Partial<BlockInput> = {}): BlockInput => ({
|
||||||
|
startMinutes: 6 * 60,
|
||||||
|
endMinutes: 8 * 60,
|
||||||
|
proclamationIds: ['p1'],
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
const sibling = (startMinutes: number, endMinutes: number, id = 'b1'): BlockSibling => ({
|
||||||
|
id,
|
||||||
|
startMinutes,
|
||||||
|
endMinutes
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatTime', () => {
|
||||||
|
it('formats 12-hour clock labels', () => {
|
||||||
|
expect(formatTime(0)).toBe('12:00 AM');
|
||||||
|
expect(formatTime(6 * 60)).toBe('6:00 AM');
|
||||||
|
expect(formatTime(8 * 60 + 5)).toBe('8:05 AM');
|
||||||
|
expect(formatTime(12 * 60)).toBe('12:00 PM');
|
||||||
|
expect(formatTime(13 * 60 + 30)).toBe('1:30 PM');
|
||||||
|
expect(formatTime(23 * 60 + 59)).toBe('11:59 PM');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toHm', () => {
|
||||||
|
it('formats 24-hour HH:MM strings', () => {
|
||||||
|
expect(toHm(0)).toBe('00:00');
|
||||||
|
expect(toHm(6 * 60)).toBe('06:00');
|
||||||
|
expect(toHm(23 * 60 + 59)).toBe('23:59');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('snapMinutes', () => {
|
||||||
|
it('rounds to the nearest 15 minutes and clamps to the day', () => {
|
||||||
|
expect(snapMinutes(0)).toBe(0);
|
||||||
|
expect(snapMinutes(7)).toBe(0);
|
||||||
|
expect(snapMinutes(8)).toBe(15);
|
||||||
|
expect(snapMinutes(370)).toBe(375);
|
||||||
|
expect(snapMinutes(-10)).toBe(0);
|
||||||
|
expect(snapMinutes(2000)).toBe(1440);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isOvernight', () => {
|
||||||
|
it('is true when the end clock time is before the start', () => {
|
||||||
|
expect(isOvernight({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(true);
|
||||||
|
expect(isOvernight({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is false for same-day ranges and a full-day range', () => {
|
||||||
|
expect(isOvernight({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(false);
|
||||||
|
expect(isOvernight({ startMinutes: 0, endMinutes: 1440 })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blockDuration', () => {
|
||||||
|
it('counts a same-day range directly', () => {
|
||||||
|
expect(blockDuration({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts an overnight range as wrapping to the next day', () => {
|
||||||
|
expect(blockDuration({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toBe(720);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an end of midnight as wrapping from the start', () => {
|
||||||
|
expect(blockDuration({ startMinutes: 22 * 60, endMinutes: 0 })).toBe(120);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('blockSegments', () => {
|
||||||
|
it('returns a single segment for a same-day range', () => {
|
||||||
|
expect(blockSegments({ startMinutes: 6 * 60, endMinutes: 8 * 60 })).toEqual([[6 * 60, 8 * 60]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splits an overnight range at midnight', () => {
|
||||||
|
expect(blockSegments({ startMinutes: 21 * 60, endMinutes: 9 * 60 })).toEqual([
|
||||||
|
[21 * 60, 1440],
|
||||||
|
[0, 9 * 60]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no segments for a zero-length range', () => {
|
||||||
|
expect(blockSegments({ startMinutes: 360, endMinutes: 360 })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findOverlap', () => {
|
||||||
|
it('returns null for adjacent ranges', () => {
|
||||||
|
expect(
|
||||||
|
findOverlap({ startMinutes: 8 * 60, endMinutes: 9 * 60 }, [sibling(6 * 60, 8 * 60)])
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the sibling for a partial overlap', () => {
|
||||||
|
const other = sibling(7 * 60, 9 * 60);
|
||||||
|
expect(findOverlap({ startMinutes: 8 * 60, endMinutes: 10 * 60 }, [other])).toBe(other);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects an overnight block overlapping an early-morning block', () => {
|
||||||
|
const other = sibling(8 * 60, 10 * 60);
|
||||||
|
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects an overnight block overlapping a late-evening block', () => {
|
||||||
|
const other = sibling(20 * 60, 22 * 60);
|
||||||
|
expect(findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [other])).toBe(other);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an overnight block and its exact complement (adjacent at both ends)', () => {
|
||||||
|
expect(
|
||||||
|
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(9 * 60, 21 * 60)])
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows an overnight block and a midday block it does not touch', () => {
|
||||||
|
expect(
|
||||||
|
findOverlap({ startMinutes: 21 * 60, endMinutes: 9 * 60 }, [sibling(10 * 60, 20 * 60)])
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateBlock', () => {
|
||||||
|
it('accepts a valid block with no siblings', () => {
|
||||||
|
expect(validateBlock(input(), [], { isCreate: true })).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects equal start and end times', () => {
|
||||||
|
const result = validateBlock(input({ endMinutes: 6 * 60 }), []);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Start and end time can't be the same."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a time outside the 24-hour range', () => {
|
||||||
|
expect(validateBlock(input({ startMinutes: 23 * 60, endMinutes: 25 * 60 }), [])).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'Enter a valid start and end time.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a negative start time', () => {
|
||||||
|
expect(validateBlock(input({ startMinutes: -1 }), [])).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'Enter a valid start and end time.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an overnight block', () => {
|
||||||
|
expect(
|
||||||
|
validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [], {
|
||||||
|
isCreate: true
|
||||||
|
})
|
||||||
|
).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an overnight block that overlaps another and names it', () => {
|
||||||
|
const result = validateBlock(input({ startMinutes: 21 * 60, endMinutes: 9 * 60 }), [
|
||||||
|
sibling(8 * 60, 10 * 60)
|
||||||
|
]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'This overlaps with your 8:00 AM–10:00 AM block.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires at least one proclamation', () => {
|
||||||
|
expect(validateBlock(input({ proclamationIds: [] }), [])).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'Select at least one proclamation.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the four-block maximum on create', () => {
|
||||||
|
const siblings = [
|
||||||
|
sibling(0, 60, 'a'),
|
||||||
|
sibling(60, 120, 'b'),
|
||||||
|
sibling(120, 180, 'c'),
|
||||||
|
sibling(180, 240, 'd')
|
||||||
|
];
|
||||||
|
const result = validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings, {
|
||||||
|
isCreate: true
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows adjacent blocks (end exactly equals next start)', () => {
|
||||||
|
expect(
|
||||||
|
validateBlock(input({ startMinutes: 8 * 60, endMinutes: 9 * 60 }), [sibling(6 * 60, 8 * 60)])
|
||||||
|
).toEqual({
|
||||||
|
ok: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an exact duplicate range and names the conflicting block', () => {
|
||||||
|
const result = validateBlock(input(), [sibling(6 * 60, 8 * 60)]);
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'This overlaps with your 6:00 AM–8:00 AM block.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a partial overlap', () => {
|
||||||
|
const result = validateBlock(input({ startMinutes: 7 * 60, endMinutes: 9 * 60 }), [
|
||||||
|
sibling(8 * 60, 10 * 60)
|
||||||
|
]);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a block that contains another block', () => {
|
||||||
|
const result = validateBlock(input({ startMinutes: 5 * 60, endMinutes: 11 * 60 }), [
|
||||||
|
sibling(6 * 60, 8 * 60)
|
||||||
|
]);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply the maximum when updating (isCreate false)', () => {
|
||||||
|
const siblings = [sibling(0, 60, 'a'), sibling(60, 120, 'b'), sibling(120, 180, 'c')];
|
||||||
|
expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Isomorphic, dependency-free schedule helpers. Used by the server service
|
||||||
|
// layer, the v2 serializers and the client-side Block modal (for live
|
||||||
|
// pre-validation), so it must not import Prisma or any server-only code.
|
||||||
|
|
||||||
|
export const MAX_BLOCKS = 4;
|
||||||
|
export const MINUTES_PER_DAY = 24 * 60;
|
||||||
|
export const SNAP_MINUTES = 15;
|
||||||
|
export const MINUTE_STEP = 5;
|
||||||
|
|
||||||
|
export type BlockSibling = {
|
||||||
|
id?: string;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BlockInput = {
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
proclamationIds: string[];
|
||||||
|
/** When true the kiosk enables dynamic audio for the block's duration. */
|
||||||
|
dynamicAudio?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ValidationResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
function normalizeMinutes(minutes: number): number {
|
||||||
|
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format minutes-from-midnight as a 12-hour clock label, e.g. `6:00 AM`. */
|
||||||
|
export function formatTime(minutes: number): string {
|
||||||
|
const normalized = normalizeMinutes(minutes);
|
||||||
|
const hours24 = Math.floor(normalized / 60);
|
||||||
|
const mins = normalized % 60;
|
||||||
|
const period = hours24 < 12 ? 'AM' : 'PM';
|
||||||
|
const hours12 = hours24 % 12 === 0 ? 12 : hours24 % 12;
|
||||||
|
return `${hours12}:${String(mins).padStart(2, '0')} ${period}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format minutes-from-midnight as a 24-hour `HH:MM` string. */
|
||||||
|
export function toHm(minutes: number): string {
|
||||||
|
const normalized = normalizeMinutes(minutes);
|
||||||
|
const hours = Math.floor(normalized / 60);
|
||||||
|
const mins = normalized % 60;
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */
|
||||||
|
export function snapMinutes(minutes: number): number {
|
||||||
|
const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;
|
||||||
|
return Math.max(0, Math.min(MINUTES_PER_DAY, snapped));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Block whose end clock time is at or before its start wraps past midnight. */
|
||||||
|
export function isOvernight(block: { startMinutes: number; endMinutes: number }): boolean {
|
||||||
|
return block.endMinutes <= block.startMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block length in minutes, counting a wrap past midnight as the next day. */
|
||||||
|
export function blockDuration(block: { startMinutes: number; endMinutes: number }): number {
|
||||||
|
if (block.endMinutes > block.startMinutes) {
|
||||||
|
return block.endMinutes - block.startMinutes;
|
||||||
|
}
|
||||||
|
return block.endMinutes - block.startMinutes + MINUTES_PER_DAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a Block into the linear half-open `[start, end)` segments it occupies
|
||||||
|
* within the 24h day. An overnight block produces two segments (start→midnight
|
||||||
|
* and midnight→end); a zero-length block produces none.
|
||||||
|
*/
|
||||||
|
export function blockSegments(block: {
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
}): [number, number][] {
|
||||||
|
if (block.startMinutes < block.endMinutes) {
|
||||||
|
return [[block.startMinutes, block.endMinutes]];
|
||||||
|
}
|
||||||
|
if (block.startMinutes > block.endMinutes) {
|
||||||
|
return [
|
||||||
|
[block.startMinutes, MINUTES_PER_DAY],
|
||||||
|
[0, block.endMinutes]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentsIntersect(a: [number, number], b: [number, number]): boolean {
|
||||||
|
return a[0] < b[1] && b[0] < a[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First sibling whose occupied time intersects the input. Works for overnight
|
||||||
|
* (midnight-crossing) blocks by comparing their linear segments; ranges that
|
||||||
|
* only touch at an endpoint (e.g. 9PM–9AM vs 9AM–9PM) do not overlap.
|
||||||
|
*/
|
||||||
|
export function findOverlap(
|
||||||
|
input: { startMinutes: number; endMinutes: number },
|
||||||
|
siblings: BlockSibling[]
|
||||||
|
): BlockSibling | null {
|
||||||
|
const inputSegments = blockSegments(input);
|
||||||
|
for (const sibling of siblings) {
|
||||||
|
const siblingSegments = blockSegments(sibling);
|
||||||
|
for (const segment of inputSegments) {
|
||||||
|
for (const other of siblingSegments) {
|
||||||
|
if (segmentsIntersect(segment, other)) return sibling;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a Block against the schedule's other Blocks. `siblings` must exclude
|
||||||
|
* the Block being edited. `isCreate` enables the 4-block maximum (an update
|
||||||
|
* never increases the count).
|
||||||
|
*/
|
||||||
|
export function validateBlock(
|
||||||
|
input: BlockInput,
|
||||||
|
siblings: BlockSibling[],
|
||||||
|
opts: { isCreate?: boolean } = {}
|
||||||
|
): ValidationResult {
|
||||||
|
const { startMinutes, endMinutes, proclamationIds } = input;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isInteger(startMinutes) ||
|
||||||
|
!Number.isInteger(endMinutes) ||
|
||||||
|
startMinutes < 0 ||
|
||||||
|
startMinutes > MINUTES_PER_DAY ||
|
||||||
|
endMinutes < 0 ||
|
||||||
|
endMinutes > MINUTES_PER_DAY
|
||||||
|
) {
|
||||||
|
return { ok: false, error: 'Enter a valid start and end time.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal clock times would be a zero- or full-length block; both are
|
||||||
|
// ambiguous, so reject them. Overnight ranges (end before start) are valid.
|
||||||
|
if (startMinutes === endMinutes) {
|
||||||
|
return { ok: false, error: "Start and end time can't be the same." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
|
||||||
|
return { ok: false, error: 'Select at least one proclamation.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.isCreate && siblings.length >= MAX_BLOCKS) {
|
||||||
|
return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const overlap = findOverlap(input, siblings);
|
||||||
|
if (overlap) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `This overlaps with your ${formatTime(overlap.startMinutes)}–${formatTime(
|
||||||
|
overlap.endMinutes
|
||||||
|
)} block.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
schedule: { findUnique: vi.fn(), create: vi.fn() },
|
||||||
|
block: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule';
|
||||||
|
|
||||||
|
const scheduleFindUnique = prisma.schedule.findUnique as ReturnType<typeof vi.fn>;
|
||||||
|
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
|
||||||
|
const blockFindMany = prisma.block.findMany as ReturnType<typeof vi.fn>;
|
||||||
|
const blockFindFirst = prisma.block.findFirst as ReturnType<typeof vi.fn>;
|
||||||
|
const blockCreate = prisma.block.create as ReturnType<typeof vi.fn>;
|
||||||
|
const blockUpdate = prisma.block.update as ReturnType<typeof vi.fn>;
|
||||||
|
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const validInput = {
|
||||||
|
startMinutes: 6 * 60,
|
||||||
|
endMinutes: 8 * 60,
|
||||||
|
proclamationIds: ['p1']
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOrCreateSchedule', () => {
|
||||||
|
it('returns the existing schedule', async () => {
|
||||||
|
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
|
||||||
|
|
||||||
|
const result = await getOrCreateSchedule('u1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ id: 's1', userId: 'u1' });
|
||||||
|
expect(scheduleCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the singleton schedule when missing', async () => {
|
||||||
|
scheduleFindUnique.mockResolvedValue(null);
|
||||||
|
scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' });
|
||||||
|
|
||||||
|
await getOrCreateSchedule('u1');
|
||||||
|
|
||||||
|
expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listBlocks', () => {
|
||||||
|
it('scopes to the user and includes proclamations with frequencies', async () => {
|
||||||
|
blockFindMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
await listBlocks('u1');
|
||||||
|
|
||||||
|
expect(blockFindMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { schedule: { userId: 'u1' } },
|
||||||
|
orderBy: { startMinutes: 'asc' },
|
||||||
|
include: expect.objectContaining({ proclamations: expect.anything() })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createBlock', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a block with deduped proclamations in order', async () => {
|
||||||
|
blockFindMany.mockResolvedValue([]);
|
||||||
|
blockCreate.mockResolvedValue({ id: 'new-block' });
|
||||||
|
|
||||||
|
const result = await createBlock('u1', {
|
||||||
|
...validInput,
|
||||||
|
proclamationIds: ['p1', 'p1', 'p2']
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, id: 'new-block' });
|
||||||
|
expect(blockCreate).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
scheduleId: 's1',
|
||||||
|
startMinutes: validInput.startMinutes,
|
||||||
|
endMinutes: validInput.endMinutes,
|
||||||
|
dynamicAudio: false,
|
||||||
|
proclamations: {
|
||||||
|
create: [
|
||||||
|
{ proclamationId: 'p1', sortOrder: 0 },
|
||||||
|
{ proclamationId: 'p2', sortOrder: 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the dynamic audio flag', async () => {
|
||||||
|
blockFindMany.mockResolvedValue([]);
|
||||||
|
blockCreate.mockResolvedValue({ id: 'new-block' });
|
||||||
|
|
||||||
|
await createBlock('u1', { ...validInput, dynamicAudio: true });
|
||||||
|
|
||||||
|
expect(blockCreate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ dynamicAudio: true })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a fifth block without creating', async () => {
|
||||||
|
blockFindMany.mockResolvedValue([
|
||||||
|
{ id: 'a', startMinutes: 0, endMinutes: 60 },
|
||||||
|
{ id: 'b', startMinutes: 60, endMinutes: 120 },
|
||||||
|
{ id: 'c', startMinutes: 120, endMinutes: 180 },
|
||||||
|
{ id: 'd', startMinutes: 180, endMinutes: 240 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await createBlock('u1', validInput);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, error: 'You can have at most 4 blocks.' });
|
||||||
|
expect(blockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an overlapping block with a specific message', async () => {
|
||||||
|
blockFindMany.mockResolvedValue([{ id: 'a', startMinutes: 7 * 60, endMinutes: 9 * 60 }]);
|
||||||
|
|
||||||
|
const result = await createBlock('u1', validInput);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'This overlaps with your 7:00 AM–9:00 AM block.'
|
||||||
|
});
|
||||||
|
expect(blockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('updateBlock', () => {
|
||||||
|
it('returns not found when the block does not belong to the user', async () => {
|
||||||
|
blockFindFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await updateBlock('u1', 'missing', validInput);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, error: 'Block not found.' });
|
||||||
|
expect(blockUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces proclamations and excludes itself from overlap checks', async () => {
|
||||||
|
blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' });
|
||||||
|
blockFindMany.mockResolvedValue([]);
|
||||||
|
blockUpdate.mockResolvedValue({ id: 'b1' });
|
||||||
|
|
||||||
|
const result = await updateBlock('u1', 'b1', validInput);
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, id: 'b1' });
|
||||||
|
expect(blockFindMany).toHaveBeenCalledWith({
|
||||||
|
where: { scheduleId: 's1', id: { not: 'b1' } },
|
||||||
|
select: { id: true, startMinutes: true, endMinutes: true }
|
||||||
|
});
|
||||||
|
expect(blockUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'b1' },
|
||||||
|
data: {
|
||||||
|
startMinutes: validInput.startMinutes,
|
||||||
|
endMinutes: validInput.endMinutes,
|
||||||
|
dynamicAudio: false,
|
||||||
|
proclamations: {
|
||||||
|
deleteMany: {},
|
||||||
|
create: [{ proclamationId: 'p1', sortOrder: 0 }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deleteBlock', () => {
|
||||||
|
it('returns not found when the block does not belong to the user', async () => {
|
||||||
|
blockFindFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const result = await deleteBlock('u1', 'missing');
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, error: 'Block not found.' });
|
||||||
|
expect(blockDelete).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes an owned block', async () => {
|
||||||
|
blockFindFirst.mockResolvedValue({ id: 'b1' });
|
||||||
|
blockDelete.mockResolvedValue({ id: 'b1' });
|
||||||
|
|
||||||
|
const result = await deleteBlock('u1', 'b1');
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true, id: 'b1' });
|
||||||
|
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
// Schedule service layer. Owns the database orchestration and enforcement of
|
||||||
|
// the Block business rules (at most 4 per user, at least one Proclamation,
|
||||||
|
// no overnight ranges, no overlaps). Pure validation lives in
|
||||||
|
// `$lib/schedule-utils` so the client modal can reuse it.
|
||||||
|
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { validateBlock, type BlockInput } from '$lib/schedule-utils';
|
||||||
|
|
||||||
|
export {
|
||||||
|
MAX_BLOCKS,
|
||||||
|
MINUTES_PER_DAY,
|
||||||
|
SNAP_MINUTES,
|
||||||
|
MINUTE_STEP,
|
||||||
|
formatTime,
|
||||||
|
toHm,
|
||||||
|
snapMinutes,
|
||||||
|
findOverlap,
|
||||||
|
validateBlock
|
||||||
|
} from '$lib/schedule-utils';
|
||||||
|
export type { BlockInput, BlockSibling, ValidationResult } from '$lib/schedule-utils';
|
||||||
|
|
||||||
|
export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string };
|
||||||
|
|
||||||
|
function uniqueIds(ids: string[]): string[] {
|
||||||
|
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure the user has a Schedule row, creating the singleton on first use. */
|
||||||
|
export async function getOrCreateSchedule(userId: string) {
|
||||||
|
const existing = await prisma.schedule.findUnique({ where: { userId } });
|
||||||
|
if (existing) return existing;
|
||||||
|
return prisma.schedule.create({ data: { userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's Blocks with their selected Proclamations (and frequencies for
|
||||||
|
* canonical-id mapping), ordered by start time.
|
||||||
|
*/
|
||||||
|
export async function listBlocks(userId: string) {
|
||||||
|
return prisma.block.findMany({
|
||||||
|
where: { schedule: { userId } },
|
||||||
|
orderBy: { startMinutes: 'asc' },
|
||||||
|
include: {
|
||||||
|
proclamations: {
|
||||||
|
orderBy: { sortOrder: 'asc' },
|
||||||
|
include: {
|
||||||
|
proclamation: { include: { solfeggioFrequency: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBlock(userId: string, input: BlockInput): Promise<BlockMutationResult> {
|
||||||
|
const schedule = await getOrCreateSchedule(userId);
|
||||||
|
const siblings = await prisma.block.findMany({
|
||||||
|
where: { scheduleId: schedule.id },
|
||||||
|
select: { id: true, startMinutes: true, endMinutes: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const validation = validateBlock(input, siblings, { isCreate: true });
|
||||||
|
if (!validation.ok) return validation;
|
||||||
|
|
||||||
|
const proclamationIds = uniqueIds(input.proclamationIds);
|
||||||
|
const block = await prisma.block.create({
|
||||||
|
data: {
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
startMinutes: input.startMinutes,
|
||||||
|
endMinutes: input.endMinutes,
|
||||||
|
dynamicAudio: input.dynamicAudio === true,
|
||||||
|
proclamations: {
|
||||||
|
create: proclamationIds.map((proclamationId, index) => ({
|
||||||
|
proclamationId,
|
||||||
|
sortOrder: index
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, id: block.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateBlock(
|
||||||
|
userId: string,
|
||||||
|
blockId: string,
|
||||||
|
input: BlockInput
|
||||||
|
): Promise<BlockMutationResult> {
|
||||||
|
const block = await prisma.block.findFirst({
|
||||||
|
where: { id: blockId, schedule: { userId } },
|
||||||
|
select: { id: true, scheduleId: true }
|
||||||
|
});
|
||||||
|
if (!block) return { ok: false, error: 'Block not found.' };
|
||||||
|
|
||||||
|
const siblings = await prisma.block.findMany({
|
||||||
|
where: { scheduleId: block.scheduleId, id: { not: blockId } },
|
||||||
|
select: { id: true, startMinutes: true, endMinutes: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
const validation = validateBlock(input, siblings);
|
||||||
|
if (!validation.ok) return validation;
|
||||||
|
|
||||||
|
const proclamationIds = uniqueIds(input.proclamationIds);
|
||||||
|
await prisma.block.update({
|
||||||
|
where: { id: blockId },
|
||||||
|
data: {
|
||||||
|
startMinutes: input.startMinutes,
|
||||||
|
endMinutes: input.endMinutes,
|
||||||
|
dynamicAudio: input.dynamicAudio === true,
|
||||||
|
proclamations: {
|
||||||
|
deleteMany: {},
|
||||||
|
create: proclamationIds.map((proclamationId, index) => ({
|
||||||
|
proclamationId,
|
||||||
|
sortOrder: index
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ok: true, id: blockId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteBlock(userId: string, blockId: string): Promise<BlockMutationResult> {
|
||||||
|
const block = await prisma.block.findFirst({
|
||||||
|
where: { id: blockId, schedule: { userId } },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
if (!block) return { ok: false, error: 'Block not found.' };
|
||||||
|
|
||||||
|
await prisma.block.delete({ where: { id: blockId } });
|
||||||
|
return { ok: true, id: blockId };
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils';
|
||||||
|
|
||||||
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
|
// Canonical solfeggio id <- Hz, mirroring the kiosk's `frequencyIdForHz()`
|
||||||
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
|
// map (radiantwave-rebuild src/audio/audio_downloader.cpp). v2 must emit these
|
||||||
// canonical ids (1-9), never the portal DB autoincrement ids, because
|
// canonical ids (1-9), never the portal DB autoincrement ids, because
|
||||||
@@ -138,3 +140,46 @@ export function serializeColorPalette(palette: {
|
|||||||
}): V2ColorPalette {
|
}): V2ColorPalette {
|
||||||
return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors };
|
return { solfeggioFrequencyId: palette.solfeggioFrequencyId, colors: palette.colors };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type V2ScheduleBlock = {
|
||||||
|
id: string;
|
||||||
|
startTime: string;
|
||||||
|
startMinutes: number;
|
||||||
|
endTime: string;
|
||||||
|
endMinutes: number;
|
||||||
|
overnight: boolean;
|
||||||
|
durationMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamationIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type V2Schedule = {
|
||||||
|
email: string;
|
||||||
|
blocks: V2ScheduleBlock[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Build a schedule Block for the kiosk. Only the selected Proclamation IDs are
|
||||||
|
* returned; the kiosk fetches proclamation content separately. `overnight`
|
||||||
|
* marks a range that crosses midnight (end clock time before start), and
|
||||||
|
* `durationMinutes` counts the wrap as the next day. */
|
||||||
|
export function serializeScheduleBlock(
|
||||||
|
block: {
|
||||||
|
id: string;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio?: boolean;
|
||||||
|
},
|
||||||
|
proclamationIds: string[]
|
||||||
|
): V2ScheduleBlock {
|
||||||
|
return {
|
||||||
|
id: block.id,
|
||||||
|
startTime: toHm(block.startMinutes),
|
||||||
|
startMinutes: block.startMinutes,
|
||||||
|
endTime: toHm(block.endMinutes),
|
||||||
|
endMinutes: block.endMinutes,
|
||||||
|
overnight: isOvernight(block),
|
||||||
|
durationMinutes: blockDuration(block),
|
||||||
|
dynamicAudio: block.dynamicAudio === true,
|
||||||
|
proclamationIds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -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: [] });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user