Compare commits
28
Commits
c19c13b192
...
2aa6669007
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2aa6669007 | ||
|
|
87934dfd42 | ||
|
|
220ff92e78 | ||
|
|
cc4aa8717e | ||
|
|
e9f94b72ee | ||
|
|
04e1f79f37 | ||
|
|
bbf4ac12ee | ||
|
|
bf465eca60 | ||
|
|
6a3ecaf734 | ||
|
|
e6021a6345 | ||
|
|
be303dcd9c | ||
|
|
fcb5db458b | ||
|
|
242be5d241 | ||
|
|
68a979f1a1 | ||
|
|
f6c937b892 | ||
|
|
9a2888af36 | ||
|
|
3bb8e0189b | ||
|
|
138bf03ea9 | ||
|
|
25b5eeb144 | ||
|
|
3c49f425d8 | ||
|
|
8412dfb69c | ||
|
|
1d76e8dff9 | ||
|
|
179df1e252 | ||
|
|
35558b0652 | ||
|
|
6c89a89050 | ||
|
|
dca9c54d83 | ||
|
|
051520b941 | ||
|
|
64715c91d3 |
@@ -6,12 +6,15 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: build-server
|
runs-on: orion-lxc
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
git clone ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/portal.git .
|
rm -rf ./* ./.[!.]* 2>/dev/null || true
|
||||||
|
git config --global http.extraheader "Authorization: token ${{ github.token }}"
|
||||||
|
git clone --branch "${{ gitea.ref_name }}" \
|
||||||
|
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||||
|
|
||||||
- name: Build image
|
- name: Build image
|
||||||
run: |
|
run: |
|
||||||
@@ -34,12 +37,15 @@ jobs:
|
|||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
needs: build-and-push
|
needs: build-and-push
|
||||||
runs-on: build-server
|
runs-on: orion-lxc
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
run: |
|
run: |
|
||||||
git clone ssh://git@gitea.radiantwavetech.com:2222/RadiantWaveTech/portal.git .
|
rm -rf ./* ./.[!.]* 2>/dev/null || true
|
||||||
|
git config --global http.extraheader "Authorization: token ${{ github.token }}"
|
||||||
|
git clone --branch "${{ gitea.ref_name }}" \
|
||||||
|
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: https://github.com/actions/setup-node@v4
|
uses: https://github.com/actions/setup-node@v4
|
||||||
@@ -53,4 +59,4 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||||
NODE_EXTRA_CA_CERTS: ./digital-ocean.crt
|
NODE_EXTRA_CA_CERTS: ./digital-ocean.crt
|
||||||
run: npm run deploy
|
run: npm run deploy
|
||||||
Vendored
-27
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "0.2.0",
|
|
||||||
"configurations": [
|
|
||||||
{
|
|
||||||
"name": "Backend (server-side code)",
|
|
||||||
"type": "node",
|
|
||||||
"request": "launch",
|
|
||||||
"runtimeExecutable": "npm",
|
|
||||||
"runtimeArgs": ["run", "dev"],
|
|
||||||
"cwd": "${workspaceFolder}",
|
|
||||||
"console": "integratedTerminal",
|
|
||||||
"skipFiles": ["<node_internals>/**"],
|
|
||||||
"autoAttachChildProcesses": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Frontend (client-side code)",
|
|
||||||
"type": "chrome",
|
|
||||||
"request": "launch",
|
|
||||||
"preLaunchTask": "Start Dev Server",
|
|
||||||
"url": "http://localhost:5173",
|
|
||||||
"webRoot": "${workspaceFolder}",
|
|
||||||
"sourceMapPathOverrides": {
|
|
||||||
"vite:///src/*": "${webRoot}/src/*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Vendored
-1
@@ -1 +0,0 @@
|
|||||||
{}
|
|
||||||
Vendored
-26
@@ -1,26 +0,0 @@
|
|||||||
{
|
|
||||||
"version": "2.0.0",
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"label": "Start Dev Server",
|
|
||||||
"type": "shell",
|
|
||||||
"command": "npm run dev -- --port 5173 --strictPort",
|
|
||||||
"isBackground": true,
|
|
||||||
"problemMatcher": {
|
|
||||||
"pattern": [
|
|
||||||
{
|
|
||||||
"regexp": "^.*$",
|
|
||||||
"file": 0,
|
|
||||||
"location": 0,
|
|
||||||
"message": 0
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"background": {
|
|
||||||
"activeOnStart": true,
|
|
||||||
"beginsPattern": "^.*VITE.*v",
|
|
||||||
"endsPattern": "^.*localhost:.*$"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"version": "sched-adjacent-0001",
|
||||||
|
"type": "daily",
|
||||||
|
"daily": [
|
||||||
|
{
|
||||||
|
"id": "dd111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "06:00",
|
||||||
|
"startMinutes": 360,
|
||||||
|
"endTime": "07:00",
|
||||||
|
"endMinutes": 420,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 60,
|
||||||
|
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
|
||||||
|
"dynamicAudio": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dd222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "07:00",
|
||||||
|
"startMinutes": 420,
|
||||||
|
"endTime": "08:30",
|
||||||
|
"endMinutes": 510,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 90,
|
||||||
|
"proclamationIds": [
|
||||||
|
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b",
|
||||||
|
"33333333-3333-4333-8333-333333333333"
|
||||||
|
],
|
||||||
|
"dynamicAudio": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weekly": {},
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "dd111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "06:00",
|
||||||
|
"startMinutes": 360,
|
||||||
|
"endTime": "07:00",
|
||||||
|
"endMinutes": 420,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 60,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "dd222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "07:00",
|
||||||
|
"startMinutes": 420,
|
||||||
|
"endTime": "08:30",
|
||||||
|
"endMinutes": 510,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 90,
|
||||||
|
"dynamicAudio": false,
|
||||||
|
"proclamationIds": [
|
||||||
|
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b",
|
||||||
|
"33333333-3333-4333-8333-333333333333"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"version": "sched-daily-0001",
|
||||||
|
"type": "daily",
|
||||||
|
"daily": [
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "07:00",
|
||||||
|
"startMinutes": 420,
|
||||||
|
"endTime": "09:00",
|
||||||
|
"endMinutes": 540,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"proclamationIds": [
|
||||||
|
"b40fdcb9-703c-420e-af32-a081964c6f08",
|
||||||
|
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
|
||||||
|
],
|
||||||
|
"dynamicAudio": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "18:00",
|
||||||
|
"startMinutes": 1080,
|
||||||
|
"endTime": "20:30",
|
||||||
|
"endMinutes": 1230,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 150,
|
||||||
|
"proclamationIds": ["33333333-3333-4333-8333-333333333333"],
|
||||||
|
"dynamicAudio": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weekly": {},
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "07:00",
|
||||||
|
"startMinutes": 420,
|
||||||
|
"endTime": "09:00",
|
||||||
|
"endMinutes": 540,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": [
|
||||||
|
"b40fdcb9-703c-420e-af32-a081964c6f08",
|
||||||
|
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "18:00",
|
||||||
|
"startMinutes": 1080,
|
||||||
|
"endTime": "20:30",
|
||||||
|
"endMinutes": 1230,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 150,
|
||||||
|
"dynamicAudio": false,
|
||||||
|
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"version": "sched-dynamic-0001",
|
||||||
|
"type": "daily",
|
||||||
|
"daily": [
|
||||||
|
{
|
||||||
|
"id": "ee111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "08:00",
|
||||||
|
"startMinutes": 480,
|
||||||
|
"endTime": "10:00",
|
||||||
|
"endMinutes": 600,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
|
||||||
|
"dynamicAudio": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ee222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "20:00",
|
||||||
|
"startMinutes": 1200,
|
||||||
|
"endTime": "22:00",
|
||||||
|
"endMinutes": 1320,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"],
|
||||||
|
"dynamicAudio": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weekly": {},
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "ee111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "08:00",
|
||||||
|
"startMinutes": 480,
|
||||||
|
"endTime": "10:00",
|
||||||
|
"endMinutes": 600,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "ee222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "20:00",
|
||||||
|
"startMinutes": 1200,
|
||||||
|
"endTime": "22:00",
|
||||||
|
"endMinutes": 1320,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"dynamicAudio": false,
|
||||||
|
"proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"email": "jane@example.com",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "11111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "07:00",
|
||||||
|
"startMinutes": 420,
|
||||||
|
"endTime": "09:00",
|
||||||
|
"endMinutes": 540,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": [
|
||||||
|
"b40fdcb9-703c-420e-af32-a081964c6f08",
|
||||||
|
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "22222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "18:00",
|
||||||
|
"startMinutes": 1080,
|
||||||
|
"endTime": "20:30",
|
||||||
|
"endMinutes": 1230,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 150,
|
||||||
|
"dynamicAudio": false,
|
||||||
|
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"version": "sched-midnight-0001",
|
||||||
|
"type": "daily",
|
||||||
|
"daily": [
|
||||||
|
{
|
||||||
|
"id": "cc111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "21:00",
|
||||||
|
"startMinutes": 1260,
|
||||||
|
"endTime": "09:00",
|
||||||
|
"endMinutes": 540,
|
||||||
|
"overnight": true,
|
||||||
|
"durationMinutes": 720,
|
||||||
|
"proclamationIds": [
|
||||||
|
"b40fdcb9-703c-420e-af32-a081964c6f08",
|
||||||
|
"7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"
|
||||||
|
],
|
||||||
|
"dynamicAudio": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cc222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "09:00",
|
||||||
|
"startMinutes": 540,
|
||||||
|
"endTime": "21:00",
|
||||||
|
"endMinutes": 1260,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 720,
|
||||||
|
"proclamationIds": ["33333333-3333-4333-8333-333333333333"],
|
||||||
|
"dynamicAudio": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weekly": {},
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "cc111111-1111-4111-8111-111111111111",
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cc222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "09:00",
|
||||||
|
"startMinutes": 540,
|
||||||
|
"endTime": "21:00",
|
||||||
|
"endMinutes": 1260,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 720,
|
||||||
|
"dynamicAudio": false,
|
||||||
|
"proclamationIds": ["33333333-3333-4333-8333-333333333333"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"version": "sched-none-0001",
|
||||||
|
"type": null,
|
||||||
|
"daily": [],
|
||||||
|
"weekly": {},
|
||||||
|
"blocks": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"version": "7f53efde17d291a1ecf604e266d3803d1495b986",
|
||||||
|
"type": "daily",
|
||||||
|
"daily": [
|
||||||
|
{
|
||||||
|
"id": "6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91",
|
||||||
|
"startTime": "06:00",
|
||||||
|
"startMinutes": 360,
|
||||||
|
"endTime": "07:00",
|
||||||
|
"endMinutes": 420,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 60,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "65fecafc-abc8-4796-bb12-ed8e9d99e0fe",
|
||||||
|
"startTime": "18:00",
|
||||||
|
"startMinutes": 1080,
|
||||||
|
"endTime": "20:30",
|
||||||
|
"endMinutes": 1230,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 150,
|
||||||
|
"dynamicAudio": false,
|
||||||
|
"proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"weekly": {},
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "6a9be0a4-4a1c-4a2c-929d-7b6c6fe56d91",
|
||||||
|
"startTime": "06:00",
|
||||||
|
"startMinutes": 360,
|
||||||
|
"endTime": "07:00",
|
||||||
|
"endMinutes": 420,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 60,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "65fecafc-abc8-4796-bb12-ed8e9d99e0fe",
|
||||||
|
"startTime": "18:00",
|
||||||
|
"startMinutes": 1080,
|
||||||
|
"endTime": "20:30",
|
||||||
|
"endMinutes": 1230,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 150,
|
||||||
|
"dynamicAudio": false,
|
||||||
|
"proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"version": "6de852ba72ce1ab3052150640730ee87a7d460b5",
|
||||||
|
"type": "weekly",
|
||||||
|
"daily": [],
|
||||||
|
"weekly": {
|
||||||
|
"mon": [
|
||||||
|
{
|
||||||
|
"id": "a1054b78-480a-4e87-8465-aac8d677c666",
|
||||||
|
"startTime": "06:00",
|
||||||
|
"startMinutes": 360,
|
||||||
|
"endTime": "07:00",
|
||||||
|
"endMinutes": 420,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 60,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": ["310138f3-adfc-45ce-ae52-07279cd25b48"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "25979ec9-f7da-4aa4-864f-f74b1a2da78b",
|
||||||
|
"startTime": "07:00",
|
||||||
|
"startMinutes": 420,
|
||||||
|
"endTime": "08:30",
|
||||||
|
"endMinutes": 510,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 90,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": ["3805242e-4930-4dd2-afb1-130593b1dbab"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tue": [],
|
||||||
|
"wed": [],
|
||||||
|
"thu": [],
|
||||||
|
"fri": [],
|
||||||
|
"sat": [],
|
||||||
|
"sun": [
|
||||||
|
{
|
||||||
|
"id": "e2237d98-834c-433b-8979-b56ddad2ef7a",
|
||||||
|
"startTime": "23:00",
|
||||||
|
"startMinutes": 1380,
|
||||||
|
"endTime": "01:00",
|
||||||
|
"endMinutes": 60,
|
||||||
|
"overnight": true,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"dynamicAudio": true,
|
||||||
|
"proclamationIds": ["fcd5a291-3d6e-4678-9d85-9b550e80f1c7"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"blocks": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"version": "sched-weekly-0001",
|
||||||
|
"type": "weekly",
|
||||||
|
"daily": [],
|
||||||
|
"weekly": {
|
||||||
|
"mon": [
|
||||||
|
{
|
||||||
|
"id": "a1111111-1111-4111-8111-111111111111",
|
||||||
|
"startTime": "06:00",
|
||||||
|
"startMinutes": 360,
|
||||||
|
"endTime": "08:00",
|
||||||
|
"endMinutes": 480,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
|
||||||
|
"dynamicAudio": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tue": [],
|
||||||
|
"wed": [
|
||||||
|
{
|
||||||
|
"id": "a2222222-2222-4222-8222-222222222222",
|
||||||
|
"startTime": "12:00",
|
||||||
|
"startMinutes": 720,
|
||||||
|
"endTime": "13:00",
|
||||||
|
"endMinutes": 780,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 60,
|
||||||
|
"proclamationIds": ["7c9e1a2b-3d4e-5f60-8a9b-0c1d2e3f4a5b"],
|
||||||
|
"dynamicAudio": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"thu": [],
|
||||||
|
"fri": [
|
||||||
|
{
|
||||||
|
"id": "a3333333-3333-4333-8333-333333333333",
|
||||||
|
"startTime": "18:00",
|
||||||
|
"startMinutes": 1080,
|
||||||
|
"endTime": "22:00",
|
||||||
|
"endMinutes": 1320,
|
||||||
|
"overnight": false,
|
||||||
|
"durationMinutes": 240,
|
||||||
|
"proclamationIds": ["33333333-3333-4333-8333-333333333333"],
|
||||||
|
"dynamicAudio": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sat": [],
|
||||||
|
"sun": [
|
||||||
|
{
|
||||||
|
"id": "a4444444-4444-4444-8444-444444444444",
|
||||||
|
"startTime": "23:00",
|
||||||
|
"startMinutes": 1380,
|
||||||
|
"endTime": "01:00",
|
||||||
|
"endMinutes": 60,
|
||||||
|
"overnight": true,
|
||||||
|
"durationMinutes": 120,
|
||||||
|
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
|
||||||
|
"dynamicAudio": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"blocks": []
|
||||||
|
}
|
||||||
+199
-9
@@ -245,13 +245,16 @@ paths:
|
|||||||
pairing.
|
pairing.
|
||||||
|
|
||||||
Returns every Block with its start/end times and the ordered list of
|
Returns every Block with its start/end times and the ordered list of
|
||||||
selected Proclamation IDs. Overnight blocks (an end clock time before
|
selected Proclamation IDs (the legacy daily flattening in `blocks`), and
|
||||||
the start) are supported and flagged with `overnight: true` and a
|
is extended additively with `version`, `type`, `daily`, and `weekly`.
|
||||||
wrapping `durationMinutes`. Each block also carries `dynamicAudio`;
|
Overnight blocks (an end clock time before the start) are supported and
|
||||||
when true, the kiosk enables dynamic audio for the block's duration,
|
flagged with `overnight: true` and a wrapping `durationMinutes`. Each
|
||||||
overriding any audio selections made on the device. Proclamation
|
block also carries `dynamicAudio`; when true, the kiosk enables dynamic
|
||||||
content is not included; the kiosk fetches it separately via
|
audio for the block's duration, overriding any audio selections made on
|
||||||
`/api/v2/proclamations`.
|
the device. Proclamation content is not included; the kiosk fetches it
|
||||||
|
separately via `/api/v2/proclamations`. For `type: "weekly"`, `blocks` is
|
||||||
|
an empty array (the server cannot flatten weekly in the kiosk's
|
||||||
|
timezone). This endpoint emits no ETag and never returns 304.
|
||||||
operationId: getSchedule
|
operationId: getSchedule
|
||||||
parameters:
|
parameters:
|
||||||
- in: query
|
- in: query
|
||||||
@@ -309,6 +312,138 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorMessage'
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
/api/v2/licenses/{licenseKey}/schedule:
|
||||||
|
get:
|
||||||
|
tags: [Schedule]
|
||||||
|
summary: Get a License Key's schedule (Kiosk-facing, conditional)
|
||||||
|
description: |
|
||||||
|
Kiosk-facing, License-Key-only schedule read. Returns the target payload
|
||||||
|
(`version`, `type`, `daily`, `weekly`, `blocks`). `version` is a
|
||||||
|
content-derived opaque string and is also the `ETag`; a matching
|
||||||
|
`If-None-Match` returns `304`. An unknown License Key is `404`; a key
|
||||||
|
with no Schedule returns `type: null` with empty lists. No
|
||||||
|
authentication (see the API description) beyond the 10/min/IP limit.
|
||||||
|
operationId: getLicenseSchedule
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: licenseKey
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||||||
|
- in: header
|
||||||
|
name: If-None-Match
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: Prior ETag; when it matches, returns 304.
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The target schedule payload.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
description: The quoted `version`.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/SchedulePayload'
|
||||||
|
'304':
|
||||||
|
description: Not modified; the caller keeps its cached payload.
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
'404':
|
||||||
|
description: 'Unknown license (`unknown_license`).'
|
||||||
|
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/licenses/{licenseKey}/system-name:
|
||||||
|
put:
|
||||||
|
tags: [License]
|
||||||
|
summary: Set a License Key's System Name (Kiosk-only)
|
||||||
|
description: |
|
||||||
|
Sets the human-readable System Name for a License Key. The Kiosk is the
|
||||||
|
only writer; the Portal displays the name read-only. The name is unique
|
||||||
|
per account, compared case-insensitively, when non-null. Idempotent:
|
||||||
|
re-sending the current name returns 200 without a write.
|
||||||
|
operationId: setSystemName
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: licenseKey
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [systemName]
|
||||||
|
properties:
|
||||||
|
systemName:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 40
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The stored name.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [systemName, systemNameStatus]
|
||||||
|
properties:
|
||||||
|
systemName:
|
||||||
|
type: string
|
||||||
|
systemNameStatus:
|
||||||
|
type: string
|
||||||
|
enum: [ok]
|
||||||
|
'400':
|
||||||
|
description: 'Invalid name (`invalid_name`); empty/whitespace is invalid.'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
'404':
|
||||||
|
description: 'Unknown license (`unknown_license`).'
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorMessage'
|
||||||
|
'409':
|
||||||
|
description: 'Name already used on the account (`name_taken`).'
|
||||||
|
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]
|
||||||
@@ -505,9 +640,16 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
maxLength: 254
|
maxLength: 254
|
||||||
|
systemName:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 40
|
||||||
|
description: |
|
||||||
|
Optional. The Kiosk's System Name (trimmed). Omitted means the stored
|
||||||
|
name is left untouched; empty/whitespace is invalid, never a clear.
|
||||||
LicenseResponse:
|
LicenseResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [key, email_address]
|
required: [key, email_address, systemNameStatus]
|
||||||
properties:
|
properties:
|
||||||
key:
|
key:
|
||||||
type: string
|
type: string
|
||||||
@@ -515,6 +657,12 @@ components:
|
|||||||
email_address:
|
email_address:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
|
systemNameStatus:
|
||||||
|
type: string
|
||||||
|
enum: [ok, unchanged, conflict, invalid]
|
||||||
|
description: |
|
||||||
|
Advisory outcome of the optional `systemName`. Validation itself
|
||||||
|
always succeeds. `unchanged` is returned when the field is omitted.
|
||||||
ScheduleBlock:
|
ScheduleBlock:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -576,9 +724,51 @@ components:
|
|||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [daily, weekly, null]
|
||||||
|
daily:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ScheduleBlock'
|
||||||
|
weekly:
|
||||||
|
type: object
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ScheduleBlock'
|
||||||
blocks:
|
blocks:
|
||||||
type: array
|
type: array
|
||||||
maxItems: 4
|
items:
|
||||||
|
$ref: '#/components/schemas/ScheduleBlock'
|
||||||
|
SchedulePayload:
|
||||||
|
type: object
|
||||||
|
required: [version, type, daily, weekly, blocks]
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
description: Opaque content hash; doubles as the ETag.
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [daily, weekly, null]
|
||||||
|
daily:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ScheduleBlock'
|
||||||
|
weekly:
|
||||||
|
type: object
|
||||||
|
description: Present with all seven keys when `type` is weekly.
|
||||||
|
additionalProperties:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/ScheduleBlock'
|
||||||
|
blocks:
|
||||||
|
type: array
|
||||||
|
description: Legacy daily flattening; empty for weekly.
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/ScheduleBlock'
|
$ref: '#/components/schemas/ScheduleBlock'
|
||||||
OnboardingRequest:
|
OnboardingRequest:
|
||||||
|
|||||||
@@ -0,0 +1,619 @@
|
|||||||
|
# Schedule API & Data Contract
|
||||||
|
|
||||||
|
Status: Phase 0 (contract only — no application code). This document is the
|
||||||
|
authoritative API/data contract for the multi-phase scheduler overhaul. It is
|
||||||
|
kept **byte-identical** in both repositories:
|
||||||
|
|
||||||
|
- `radiantwave` (C++/SDL3 Kiosk app) — `docs/schedule-api-contract.md`
|
||||||
|
- `portal` (SvelteKit/Node) — `docs/schedule-api-contract.md`
|
||||||
|
|
||||||
|
Any change is applied to both copies in the same step, with a dated line
|
||||||
|
appended to the [Changelog](#changelog).
|
||||||
|
|
||||||
|
Glossary terms (Kiosk, Portal, Proclamation, Category, Group, Block, Schedule,
|
||||||
|
License Key, System Name) are used with the meanings defined in the scheduler
|
||||||
|
overhaul brief.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current state
|
||||||
|
|
||||||
|
This section reflects the code as it exists at the start of Phase 0. It is
|
||||||
|
descriptive, not aspirational; the target behaviour is defined in the later
|
||||||
|
sections.
|
||||||
|
|
||||||
|
### Kiosk (`radiantwave`)
|
||||||
|
|
||||||
|
- **License validation.** `POST /api/v2/license` with body
|
||||||
|
`{"license_key": "...", "email_address": "..."}`. Implemented in
|
||||||
|
`src/network/api_client.cpp` (`validateLicense`, `parseLicenseJson`) and
|
||||||
|
consumed by `src/network/license_service.cpp` (`validateStoredLicense`,
|
||||||
|
`markLicenseVerified`, `isLicenseWithinGracePeriod`). A 2xx response is the
|
||||||
|
device's "subscribed" flag; a 7-day offline grace window is stored as
|
||||||
|
`license_last_verified` in `system_kv` (`src/db/db_keys.h`). Response body
|
||||||
|
currently read: `{key, email_address}`; additional fields are ignored.
|
||||||
|
- **Schedule fetch.** `GET /api/v2/schedule?email=<email>&licenseKey=<key>`
|
||||||
|
(`src/network/api_client.cpp`: `fetchSchedule`, `parseScheduleJson`;
|
||||||
|
`PATH_SCHEDULE = "/schedule"`). Response shape read by the Kiosk:
|
||||||
|
`{email, blocks: [{id, startMinutes, endMinutes, overnight, durationMinutes,
|
||||||
|
dynamicAudio, proclamationIds}]}`. `dynamicAudio` is optional on the wire and
|
||||||
|
defaults to `true` in `parseScheduleJson`. Missing/blank credentials cause an
|
||||||
|
early `false`.
|
||||||
|
- **Schedule persistence.** SQLite tables `schedule_blocks` and
|
||||||
|
`schedule_block_proclamations` (`src/db/database_manager.cpp:185`,
|
||||||
|
`replaceScheduleBlocks` at `:1105`, `getScheduleBlocks` at `:1169`). Full
|
||||||
|
replace; block `sort_order` and per-block proclamation `position` preserved.
|
||||||
|
No day-of-week column and no schedule `type`; the model is implicitly daily.
|
||||||
|
- **Schedule sync.** `syncScheduleFromApi` (`src/network/proclamation_sync.cpp:172`)
|
||||||
|
is invoked only as a **best-effort** side effect of
|
||||||
|
`syncProclamationsFromApi` (`:168`). There is no boot sync, no network-up
|
||||||
|
sync, no periodic timer, and no conditional GET (`If-None-Match`/ETag).
|
||||||
|
`syncProclamationsFromApi` is called from `Home::syncProclamationsIfEmpty`
|
||||||
|
(`src/home.cpp:461`) and `ProclamationOptionsPage::onPush`
|
||||||
|
(`src/proclamation_options_page.cpp:49`). There is no explicit atomic
|
||||||
|
schedule cache file; the SQLite database is the durable store.
|
||||||
|
- **Schedule playback.** `src/scroller_page.cpp` selects the active block by
|
||||||
|
comparing `timezone::currentLocalMinutes(cfg.timezone)`
|
||||||
|
(`src/timezone_helper.h`) against each block via
|
||||||
|
`scroller::minuteInScheduleBlock` (`src/scroller_streams.h:31`). Block
|
||||||
|
selection is daily-only (no day-of-week). An active block's ordered
|
||||||
|
`proclamation_ids` replace the local selection
|
||||||
|
(`src/scroller_streams.cpp:57`). `effectiveDynamicAudio()`
|
||||||
|
(`src/scroller_page.cpp:302`) forces dynamic audio when the active block has
|
||||||
|
`dynamic_audio == true`, otherwise the device-wide
|
||||||
|
`config.audio.dynamic_audio` applies. A block change is latched in
|
||||||
|
`scheduleChangePending_` and applied at the next proclamation boundary.
|
||||||
|
- **Settings persistence.** `~/.local/radiantwave/config.yaml` (YAML) via
|
||||||
|
`ConfigManager` (`src/config/config_manager.cpp`, `src/config/yaml_parser.cpp`,
|
||||||
|
schema in `src/config/app_config.h`): `timezone`, `audio.dynamic_audio`,
|
||||||
|
`proclamation.schedule_active` (Kiosk-local on/off toggle, default `false`).
|
||||||
|
Identity lives in SQLite `system_kv` (`email_address`, `license_key`,
|
||||||
|
`license_last_verified`). There is no System Name anywhere.
|
||||||
|
- **Conventions.** `API_BASE_URL = "https://portal.radiantwavetech.com/api/v2"`
|
||||||
|
(`src/network/api_client.h`). Kiosk API knowledge is mirrored in
|
||||||
|
`openapi.yaml` at the repo root.
|
||||||
|
|
||||||
|
### Portal (`portal`)
|
||||||
|
|
||||||
|
- **Framework/tooling.** SvelteKit 2 + Svelte 5 + TypeScript + Tailwind v4;
|
||||||
|
Prisma 7 over PostgreSQL; Vitest. Migrations: Prisma Migrate
|
||||||
|
(`prisma/schema.prisma`, `prisma/migrations/`, `npm run migrate`,
|
||||||
|
`npm run deploy`; config in `prisma.config.ts`). API routes under
|
||||||
|
`src/routes/api/v1/` (API-key gated) and `src/routes/api/v2/`
|
||||||
|
(unauthenticated). v2 helpers: `src/lib/server/v2-helpers.ts`
|
||||||
|
(`jsonOk`, `jsonError`, `readJsonBody`, `rateLimited`, `clientIp`); errors
|
||||||
|
are `{ "message": "<text>" }`.
|
||||||
|
- **License validation.** `POST /api/v2/license`
|
||||||
|
(`src/routes/api/v2/license/+server.ts`): validates `license_key` +
|
||||||
|
`email_address`, requires the key be active and owned by the account, stamps
|
||||||
|
`LicenseKey.lastValidatedAt`, returns `serializeLicense({key, emailAddress})`
|
||||||
|
(`src/lib/server/v2-serializers.ts`). A v1 variant exists
|
||||||
|
(`src/routes/api/v1/license/+server.ts`).
|
||||||
|
- **Schedule storage.** `Schedule` is **per user** (`userId String @unique`,
|
||||||
|
`prisma/schema.prisma:263`), daily-only, `0..4` `Block`s. `Block`
|
||||||
|
(`:275`) stores `startMinutes`/`endMinutes`, `dynamicAudio Boolean
|
||||||
|
@default(false)`, and ordered `BlockProclamations` (`sortOrder`). The schema
|
||||||
|
comment states overnight ranges are disallowed in v1, yet the serializer and
|
||||||
|
Kiosk both support `overnight` — a known inconsistency.
|
||||||
|
- **Schedule service/validation.** `src/lib/server/schedule.ts` (DB
|
||||||
|
orchestration: `getOrCreateSchedule`, `listBlocks`, `createBlock`,
|
||||||
|
`updateBlock`, `deleteBlock`) and the isomorphic
|
||||||
|
`src/lib/schedule-utils.ts` (`MAX_BLOCKS = 4`, `SNAP_MINUTES = 15`,
|
||||||
|
`validateBlock`, `findOverlap`, `isOvernight`, `blockDuration`,
|
||||||
|
`blockSegments`, `formatTime`, `toHm`, `snapMinutes`). Validation enforces a
|
||||||
|
4-block maximum on create, `startMinutes !== endMinutes`, at least one
|
||||||
|
Proclamation, and no overlaps (overnight-aware via linear segments).
|
||||||
|
- **Schedule read API.** `GET /api/v2/schedule`
|
||||||
|
(`src/routes/api/v2/schedule/+server.ts`): auth by paired `email` +
|
||||||
|
`licenseKey` (legacy alias `key`) query params; checks only that the key
|
||||||
|
belongs to the email's account (entitlement deliberately not checked); rate
|
||||||
|
limited 10/min/IP; returns `{email, blocks: [serializeScheduleBlock(...)]}`
|
||||||
|
where `serializeScheduleBlock` (`src/lib/server/v2-serializers.ts:165`) emits
|
||||||
|
`{id, startTime, startMinutes, endTime, endMinutes, overnight,
|
||||||
|
durationMinutes, dynamicAudio, proclamationIds}`.
|
||||||
|
- **Schedule UI.** Protected user route
|
||||||
|
`src/routes/(protected)/(user)/schedule/` (`+page.server.ts`,
|
||||||
|
`+page.svelte`) with `src/lib/components/schedule/DaySchedule.svelte` (single
|
||||||
|
24h day timeline, drag-to-create) and `BlockModal.svelte`. Dashboard
|
||||||
|
(`(protected)/(user)/dashboard/`) lists the user's License Keys. No System
|
||||||
|
Name is displayed or editable.
|
||||||
|
|
||||||
|
### Gaps between current state and target
|
||||||
|
|
||||||
|
- Schedule is per-user, not per-License-Key.
|
||||||
|
- No `daily`/`weekly` typing; no day-of-week storage or UI.
|
||||||
|
- No `version`/ETag, no conditional GET.
|
||||||
|
- Schedule GET requires `email` + `licenseKey`; there is no license-key-only
|
||||||
|
route, no `/api/v2/licenses/...` namespace, and no system-name route.
|
||||||
|
- `dynamicAudio` DB default is `false`; the target default for new Blocks is
|
||||||
|
`true`.
|
||||||
|
- No System Name field, validation, or display.
|
||||||
|
- Sync is opportunistic with no boot/network-up/5-minute cadence and no
|
||||||
|
atomic cache file.
|
||||||
|
- Portal stores no timezone (matches target) and shows no informational
|
||||||
|
geoip-derived timezone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Time semantics
|
||||||
|
|
||||||
|
- Times are **minute precision**, stored as 24-hour `"HH:MM"` strings
|
||||||
|
representing **local wall-clock** time. AM/PM is display-only.
|
||||||
|
- Numerators may also be carried as integer minutes-from-midnight
|
||||||
|
(`startMinutes`/`endMinutes`, `0..1440`) for the Kiosk; the string form is
|
||||||
|
canonical on the wire.
|
||||||
|
- A Block is the **half-open interval `[start, end)`**.
|
||||||
|
- A Block may start on the same minute the previous Block ends (`end 07:00`,
|
||||||
|
next `start 07:00` is valid and has no gap).
|
||||||
|
- A Block whose `end` is earlier than its `start` **crosses midnight** and
|
||||||
|
belongs to its start day. In weekly mode, Sunday's crossing Block spills into
|
||||||
|
Monday. `start == end` is **invalid** (zero- or full-length ambiguity).
|
||||||
|
- **Overlaps are invalid**, including spillover from a midnight-crossing Block
|
||||||
|
into the next day's Blocks (or, in daily mode, into the same list's early
|
||||||
|
Blocks). Touching boundaries are **not** overlaps.
|
||||||
|
- Gaps between Blocks are allowed. During a gap the Kiosk plays whatever
|
||||||
|
Proclamations are selected locally on the Kiosk.
|
||||||
|
- The Kiosk evaluates Schedules in its own Timezone setting
|
||||||
|
(`config.timezone`, IANA code). **The payload carries no timezone.** The
|
||||||
|
Portal may show the user's geoip-derived timezone informationally only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schedule data model (JSON)
|
||||||
|
|
||||||
|
Each License Key has **at most one** Schedule. `type` is `"daily"` or
|
||||||
|
`"weekly"` and determines the mode; there is no separate global mode. **No**
|
||||||
|
Schedule means `type` is `null`.
|
||||||
|
|
||||||
|
Suggested shape from the brief, adapted to existing field names (`start`/`end`
|
||||||
|
→ `startTime`/`endTime`, retaining the existing `startMinutes`/`endMinutes`
|
||||||
|
integers for the Kiosk):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "<opaque string; also used as the ETag>",
|
||||||
|
"type": "daily" | "weekly" | null,
|
||||||
|
"daily": [ Block, ... ],
|
||||||
|
"weekly": { "mon": [Block], "tue": [Block], "wed": [Block],
|
||||||
|
"thu": [Block], "fri": [Block], "sat": [Block],
|
||||||
|
"sun": [Block] },
|
||||||
|
"blocks": [ LegacyBlock, ... ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Block:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "3f1c2b7a-9d4e-4c8a-b2f6-1a2b3c4d5e6f",
|
||||||
|
"startTime": "21:00",
|
||||||
|
"startMinutes": 1260,
|
||||||
|
"endTime": "09:00",
|
||||||
|
"endMinutes": 540,
|
||||||
|
"overnight": true,
|
||||||
|
"durationMinutes": 720,
|
||||||
|
"proclamationIds": ["b40fdcb9-703c-420e-af32-a081964c6f08"],
|
||||||
|
"dynamicAudio": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `version` is an opaque string derived from the Schedule content (see
|
||||||
|
[Endpoints](#endpoints)). It is implementation-defined and must change
|
||||||
|
whenever the serialized schedule changes.
|
||||||
|
- **Only the fields for the active type are populated.** For `type: "daily"`,
|
||||||
|
`daily` is populated and `weekly` is omitted or empty. For `type: "weekly"`,
|
||||||
|
`weekly` is populated with all seven keys and `daily` is omitted or empty.
|
||||||
|
For `type: null`, both are empty/omitted.
|
||||||
|
- `weekly` always contains all seven lowercase keys `mon`..`sun`; a day with no
|
||||||
|
Blocks is an empty array.
|
||||||
|
- `blocks` is the **legacy flattening** retained for older Kiosks (see
|
||||||
|
[Backward compatibility](#backward-compatibility)). It uses the existing
|
||||||
|
`LegacyBlock` shape serialized by the current portal
|
||||||
|
(`{id, startTime, startMinutes, endTime, endMinutes, overnight,
|
||||||
|
durationMinutes, dynamicAudio, proclamationIds}`). For `type: "weekly"`,
|
||||||
|
`blocks` is an **empty array** (the server does not know the Kiosk's
|
||||||
|
timezone and cannot flatten weekly to a single day).
|
||||||
|
- `proclamationIds` is the ordered play list (order is significant);
|
||||||
|
duplicates are not meaningful and are de-duplicated by the Portal.
|
||||||
|
- `dynamicAudio` defaults to `true` for new Blocks created on the Portal.
|
||||||
|
- Proclamation **content** is never included; the Kiosk fetches content
|
||||||
|
separately via `/api/v2/proclamations`. Portal users only ever see
|
||||||
|
title/category/group.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation rules
|
||||||
|
|
||||||
|
These are enforced by the Portal (authoritative) and defended against by the
|
||||||
|
Kiosk.
|
||||||
|
|
||||||
|
1. A Schedule belongs to exactly one License Key; a License Key has at most one
|
||||||
|
Schedule.
|
||||||
|
2. `type` is one of `"daily"`, `"weekly"`, or `null`.
|
||||||
|
3. A Block has `start` and `end` times; `start == end` is invalid.
|
||||||
|
4. `end < start` denotes a midnight-crossing Block belonging to its start day.
|
||||||
|
5. Blocks within a day (and the spillover of a midnight-crossing Block into the
|
||||||
|
following day) must not overlap. Touching boundaries are allowed.
|
||||||
|
6. Weekly modes apply rules 3–5 independently per day; a `sun` Block crossing
|
||||||
|
midnight must not overlap `mon` early Blocks.
|
||||||
|
7. A Block must contain at least one Proclamation. Empty Blocks are rejected
|
||||||
|
server-side. The Kiosk defensively **skips invalid Blocks** and treats them
|
||||||
|
as gaps.
|
||||||
|
8. Gaps are allowed; during a gap local Kiosk selection applies.
|
||||||
|
9. `dynamicAudio` is a boolean; absent means `true` for new Blocks.
|
||||||
|
10. Times are minute-precision `HH:MM` strings; the Portal snaps UI input to
|
||||||
|
`SNAP_MINUTES` (15) with a `MINUTE_STEP` (5) constraint from the existing
|
||||||
|
helpers.
|
||||||
|
11. The existing Portal cap of `MAX_BLOCKS = 4` per day list is **retained**
|
||||||
|
for Phase 0 (see [Open items](#open-items)).
|
||||||
|
|
||||||
|
The Kiosk must not crash on, and must not play, an invalid Block: it skips it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
The API remains **unauthenticated** (no API keys) for v2. This is a known
|
||||||
|
limitation and is recorded under [Open items](#open-items).
|
||||||
|
|
||||||
|
All new endpoints live under the existing versioned namespace
|
||||||
|
`/api/v2/...` to preserve conventions. v1 routes are unchanged.
|
||||||
|
|
||||||
|
### (a) License validation — existing, additive
|
||||||
|
|
||||||
|
`POST /api/v2/license`
|
||||||
|
|
||||||
|
Request (existing fields unchanged; `systemName` additive and optional):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"license_key": "ABCD-EFGH-IJKL-MNOP",
|
||||||
|
"email_address": "jane@example.com",
|
||||||
|
"systemName": "Living Room"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response (existing fields unchanged; `systemNameStatus` additive):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "key": "ABCD-EFGH-IJKL-MNOP", "email_address": "jane@example.com", "systemNameStatus": "ok" }
|
||||||
|
```
|
||||||
|
|
||||||
|
- `systemName` is optional. When present it is trimmed; when absent the stored
|
||||||
|
name is left untouched.
|
||||||
|
- `systemNameStatus` ∈ `{"ok", "unchanged", "conflict", "invalid"}`:
|
||||||
|
- `ok` — name was set or updated.
|
||||||
|
- `unchanged` — omitted, or identical after trim/case-insensitive compare.
|
||||||
|
- `conflict` — another License Key on the same account already holds the name.
|
||||||
|
- `invalid` — trimmed length outside 1–40.
|
||||||
|
- **Validation itself succeeds regardless of name status.** A non-`ok` status
|
||||||
|
never fails the license check; the Kiosk treats it as advisory.
|
||||||
|
- Errors and rate limiting follow the existing endpoint (400 messages,
|
||||||
|
413 body cap, 429 with `Retry-After`).
|
||||||
|
|
||||||
|
### (b) Set System Name — Kiosk-facing
|
||||||
|
|
||||||
|
`PUT /api/v2/licenses/{licenseKey}/system-name`
|
||||||
|
|
||||||
|
Request:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "systemName": "Living Room" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Responses:
|
||||||
|
|
||||||
|
| Status | Condition | Body |
|
||||||
|
| --------------------- | ----------------------------------------- | ------------------------------------------------- |
|
||||||
|
| 200 | Name set/updated (or unchanged) | `{"systemName": "...", "systemNameStatus": "ok"}` |
|
||||||
|
| 400 `invalid_name` | Trimmed length outside 1–40 | `{"message": "invalid_name"}` |
|
||||||
|
| 404 `unknown_license` | No License Key matches | `{"message": "unknown_license"}` |
|
||||||
|
| 409 `name_taken` | Another key on the account holds the name | `{"message": "name_taken"}` |
|
||||||
|
|
||||||
|
- The Kiosk is the **only** writer. The Portal has no rename capability.
|
||||||
|
- The response body uses the existing v2 error convention
|
||||||
|
(`{"message": "<code>"}`).
|
||||||
|
- Rate limited at the existing 10 requests/min/IP convention.
|
||||||
|
|
||||||
|
### (c) Get Schedule — Kiosk-facing, key-only, conditional
|
||||||
|
|
||||||
|
`GET /api/v2/licenses/{licenseKey}/schedule`
|
||||||
|
|
||||||
|
- 200 with `ETag: "<version>"` and the payload from
|
||||||
|
[Schedule data model](#schedule-data-model-json). A License Key with no
|
||||||
|
Schedule returns 200 with `type: null` and empty lists.
|
||||||
|
- `304 Not Modified` when the request's `If-None-Match` matches the current
|
||||||
|
`ETag`.
|
||||||
|
- `404` (`unknown_license`) when no License Key matches.
|
||||||
|
- `version` doubles as the ETag value; the header value is the quoted form.
|
||||||
|
- The response is the **target** payload (`version`, `type`, `daily`, `weekly`,
|
||||||
|
`blocks`).
|
||||||
|
- Rate limited at the existing 10 requests/min/IP convention.
|
||||||
|
|
||||||
|
### (d) Legacy schedule read — existing, evolved additively
|
||||||
|
|
||||||
|
`GET /api/v2/schedule?email=<email>&licenseKey=<key>` remains supported with
|
||||||
|
its existing auth semantics. Its response is extended **additively** with the
|
||||||
|
same `version`, `type`, `daily`, and `weekly` fields, while continuing to
|
||||||
|
populate `email` and `blocks` exactly as before. Old Kiosks ignore the new
|
||||||
|
fields; new Kiosks may read either endpoint. This endpoint does **not** emit an
|
||||||
|
ETag and does **not** return 304.
|
||||||
|
|
||||||
|
### (e) Portal-internal endpoints (not Kiosk-facing)
|
||||||
|
|
||||||
|
These are session-authenticated Portal actions; they are **not** part of the
|
||||||
|
Kiosk API and are not served by `/api/v2/...`.
|
||||||
|
|
||||||
|
- Schedule CRUD — implemented in Phase 3 as SvelteKit form actions on
|
||||||
|
`(protected)/(user)/schedule` (`?/setType`, `?/createBlock`, `?/updateBlock`,
|
||||||
|
`?/deleteBlock`), backed by `$lib/server/schedule.ts`.
|
||||||
|
- **Copy** — implemented in Phase 4 as the `?/copy` action plus
|
||||||
|
`copySchedule()` in `$lib/server/schedule.ts`. Copies one License Key's
|
||||||
|
Schedule to one or more other License Keys **by value, in a single database
|
||||||
|
transaction** (all targets succeed or none). The target `type` becomes the
|
||||||
|
source's; Block rows get fresh IDs; each block's `dynamicAudio` and ordered
|
||||||
|
Proclamation references are preserved. The source and every target must
|
||||||
|
belong to the caller's account; a target equal to the source, an empty
|
||||||
|
target list, or a source without a schedule are rejected. The resulting
|
||||||
|
schedule is run through the shared validator before commit. Because Block IDs
|
||||||
|
are part of the serialized payload, every target's content-derived
|
||||||
|
`version`/ETag changes, so its Kiosk refetches.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sync behavior
|
||||||
|
|
||||||
|
Target Kiosk sync (defaults; adjust only if the code forces it):
|
||||||
|
|
||||||
|
- The Kiosk pulls the Schedule:
|
||||||
|
- on boot,
|
||||||
|
- on network-up,
|
||||||
|
- every 5 minutes.
|
||||||
|
- Pulls use conditional GET (`If-None-Match` / ETag); a `304` leaves the cache
|
||||||
|
untouched.
|
||||||
|
- The last Schedule is cached to disk with an **atomic write** and used offline.
|
||||||
|
- The schedule on/off toggle is **Kiosk-local** (existing
|
||||||
|
`proclamation.schedule_active`) and is not synced.
|
||||||
|
- With no cache and no network, the Kiosk behaves as if schedules are off
|
||||||
|
(local Proclamation selection applies).
|
||||||
|
- During gaps, or when schedules are off, the local audio selection applies.
|
||||||
|
|
||||||
|
Current-state deltas (to be closed by later phases): no boot/network-up/5-minute
|
||||||
|
cadence, no ETag, no explicit atomic cache file. These are recorded under
|
||||||
|
[Touchpoints per phase](#touchpoints-per-phase).
|
||||||
|
|
||||||
|
Identity for sync is the License Key. The legacy schedule endpoint additionally
|
||||||
|
requires the paired email; the new endpoint uses the License Key alone.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## System Name rules
|
||||||
|
|
||||||
|
- A System Name is a human-readable label for a License Key (e.g.
|
||||||
|
"Living Room"). It is a **label only** — never used as a reference or key.
|
||||||
|
- Unique **per account**, compared **case-insensitively**.
|
||||||
|
- Trimmed, **1–40 characters**.
|
||||||
|
- Set or changed **only via the Kiosk**. The Portal displays it **read-only**
|
||||||
|
and has no rename capability.
|
||||||
|
- The Portal falls back to displaying the License Key when no name is set.
|
||||||
|
- A Kiosk that omits `systemName` never clears an existing name.
|
||||||
|
- Wire representations: request field `systemName`; status field
|
||||||
|
`systemNameStatus` ∈ `{"ok","unchanged","conflict","invalid"}` (see
|
||||||
|
[Endpoints](#endpoints)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dynamic audio
|
||||||
|
|
||||||
|
- Each Block has a boolean `dynamicAudio`.
|
||||||
|
- The Portal defaults it to **`true` for new Blocks**.
|
||||||
|
- `true` ⇒ the Kiosk uses dynamic audio for that Block.
|
||||||
|
- `false` ⇒ the Kiosk uses its locally chosen audio.
|
||||||
|
- During gaps, or when schedules are off, the local audio selection applies
|
||||||
|
(existing `config.audio.dynamic_audio` / fixed track).
|
||||||
|
- Kiosk parsing defaults an absent `dynamicAudio` to `true`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backward compatibility
|
||||||
|
|
||||||
|
All existing endpoints and payloads keep working for Kiosks already in the
|
||||||
|
field. New fields are **additive**; no v2 field is removed or renamed, and v1
|
||||||
|
is untouched.
|
||||||
|
|
||||||
|
- **Old Kiosk vs new Portal.** An old Kiosk calls
|
||||||
|
`POST /api/v2/license` (no `systemName`) and
|
||||||
|
`GET /api/v2/schedule?email&licenseKey`, reading `blocks` and ignoring
|
||||||
|
unknown fields. The Portal keeps both, keeps the `blocks` shape byte-
|
||||||
|
compatible, and adds `version`/`type`/`daily`/`weekly` as extra fields. The
|
||||||
|
old Kiosk is unaffected and ignores `systemNameStatus`.
|
||||||
|
- **Daily schedules.** For `type: "daily"`, `blocks` is the flattened daily
|
||||||
|
list; an old Kiosk behaves exactly as today.
|
||||||
|
- **Weekly schedules.** `blocks` is an **empty array**. An old Kiosk therefore
|
||||||
|
sees no schedule and falls back to local Proclamation/audio selection. It
|
||||||
|
cannot interpret weekly schedules; this is accepted and recorded under
|
||||||
|
[Open items](#open-items).
|
||||||
|
- **No schedule.** `type: null`, `blocks: []`; both old and new Kiosks fall
|
||||||
|
back to local selection.
|
||||||
|
- **Omission of `systemName`** by an old Kiosk never clears an existing name;
|
||||||
|
`systemNameStatus` is simply ignored.
|
||||||
|
- The new endpoints are additive; their absence on old Kiosks is irrelevant.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Touchpoints per phase
|
||||||
|
|
||||||
|
File/module candidates each later phase will change. Phase numbering is assumed
|
||||||
|
from the brief (Phase 3 = Portal schedule CRUD, Phase 4 = Portal schedule copy)
|
||||||
|
and is recorded under [Open items](#open-items).
|
||||||
|
|
||||||
|
### Kiosk (`radiantwave`)
|
||||||
|
|
||||||
|
- **Phase 1 (Kiosk scheduler UI, storage, sync, playback)**
|
||||||
|
- `src/network/api_client.h` / `src/network/api_client.cpp` — parse
|
||||||
|
`version`/`type`/`daily`/`weekly`, ETag/`If-None-Match`, `PUT`
|
||||||
|
system-name, `systemNameStatus`.
|
||||||
|
- `src/network/proclamation_sync.h` / `.cpp` — boot/network-up/5-minute
|
||||||
|
schedule sync, conditional GET, atomic cache.
|
||||||
|
- `src/network/license_service.h` / `.cpp` — send `systemName`, handle
|
||||||
|
`systemNameStatus`.
|
||||||
|
- `src/db/db_keys.h` — System Name / ETag keys; `src/db/database_manager.h`
|
||||||
|
/ `.cpp` — schedule `type`/day-of-week storage, cache.
|
||||||
|
- `src/scroller_page.h` / `.cpp`, `src/scroller_streams.h` / `.cpp` —
|
||||||
|
day-of-week Block selection, spillover.
|
||||||
|
- `src/timezone_helper.h` — local day-of-week.
|
||||||
|
- `src/config/app_config.h`, `src/config/config_manager.cpp`,
|
||||||
|
`src/config/yaml_parser.cpp` — settings surface.
|
||||||
|
- New page(s) for setting the System Name; tests in `tests/` registered in
|
||||||
|
`CMakeLists.txt`.
|
||||||
|
|
||||||
|
### Portal (`portal`)
|
||||||
|
|
||||||
|
- **Phase 2 (System Name + license validation)**
|
||||||
|
- `prisma/schema.prisma` + migration — `LicenseKey.systemName` and
|
||||||
|
case-insensitive uniqueness.
|
||||||
|
- `src/routes/api/v2/license/+server.ts` — `systemName` /
|
||||||
|
`systemNameStatus`.
|
||||||
|
- `src/routes/api/v2/licenses/[licenseKey]/system-name/+server.ts` — new.
|
||||||
|
- `src/lib/server/v2-serializers.ts`, `src/lib/server/v2-helpers.ts`.
|
||||||
|
- Dashboard read-only display.
|
||||||
|
- **Phase 3 (Schedule CRUD: daily/weekly, per-License-Key)**
|
||||||
|
- `prisma/schema.prisma` + migration — Schedule per License Key, `type`,
|
||||||
|
day-of-week.
|
||||||
|
- `src/lib/server/schedule.ts`, `src/lib/schedule-utils.ts` — weekly,
|
||||||
|
per-license orchestration, cross-day validation.
|
||||||
|
- `src/lib/server/v2-serializers.ts` — `version`/`type`/`daily`/`weekly`.
|
||||||
|
- `src/routes/api/v2/licenses/[licenseKey]/schedule/+server.ts` — new
|
||||||
|
(ETag/304).
|
||||||
|
- `src/routes/api/v2/schedule/+server.ts` — additive evolution.
|
||||||
|
- `src/routes/(protected)/(user)/schedule/*`,
|
||||||
|
`src/lib/components/schedule/*` — weekly UI, per-license selection.
|
||||||
|
- Specs: `schedule.spec.ts`, `page.server.spec.ts`, `schedule-utils.spec.ts`.
|
||||||
|
- **Phase 4 (Schedule copy)**
|
||||||
|
- Copy service in `src/lib/server/schedule.ts`; copy route/action; UI.
|
||||||
|
- **All phases** — `docs/openapi.yaml`, this contract, `docs/fixtures/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open items
|
||||||
|
|
||||||
|
Recorded ambiguities, conservative choices, and remaining known limitations.
|
||||||
|
Items 1–21 were resolved or restated during Phases 1–4; items 22–24 are Phase 5
|
||||||
|
findings. Everything marked "Resolved" is settled for this overhaul.
|
||||||
|
|
||||||
|
1. **Phase numbering.** The brief names Phases 3 (CRUD) and 4 (copy) only;
|
||||||
|
Phase 1 (Kiosk) and Phase 2 (System Name) are assumed here.
|
||||||
|
2. **`blocks` for weekly.** Chosen as an empty array (server cannot know the
|
||||||
|
Kiosk timezone). Consequence: old Kiosks see weekly schedules as "no
|
||||||
|
schedule". Implemented in Phase 3.
|
||||||
|
3. **Block cap.** The existing `MAX_BLOCKS = 4` per day list is retained.
|
||||||
|
Phase 3 applies it **per day list** (each of the seven weekly days gets its
|
||||||
|
own 4-block budget). Whether the cap should be lower is unresolved.
|
||||||
|
4. **Field naming.** The brief's `start`/`end` were mapped to the existing
|
||||||
|
`startTime`/`endTime`; `startMinutes`/`endMinutes` retained for the Kiosk.
|
||||||
|
5. **`version` derivation.** Resolved in Phase 3: a SHA-1 content hash of the
|
||||||
|
serialized `{type, daily, weekly}` shape. Stable for equal content, changes
|
||||||
|
on any mutation.
|
||||||
|
6. **Unauthenticated key-only access.** The new endpoints authenticate by
|
||||||
|
License Key alone (per glossary). Anyone who knows a key could read the
|
||||||
|
schedule or set the name. Mitigated only by the 10/min/IP rate limit. Known
|
||||||
|
limitation.
|
||||||
|
7. **`dynamicAudio` default flip.** DB default changed `false` → `true` for
|
||||||
|
**new** Blocks in Phase 3; existing Blocks keep their stored values.
|
||||||
|
8. **Per-user → per-License-Key migration.** Resolved in Phase 3: each existing
|
||||||
|
per-user Schedule is mapped to that account's **oldest** License Key
|
||||||
|
(`createdAt ASC`), as a daily schedule. Schedules with no owning
|
||||||
|
user/license are retained with `licenseKeyId NULL` and reported via a
|
||||||
|
migration `NOTICE` (not dropped).
|
||||||
|
9. **Cross-day spillover validation.** Implemented in Phase 3: weekly
|
||||||
|
`sun`→`mon` and daily self-wrap spillover are rejected as overlaps; the
|
||||||
|
shared `validateSchedule` is authoritative and mirrored client-side.
|
||||||
|
10. **Account scope for name uniqueness.** "Per account" = the owning User.
|
||||||
|
Legacy License Keys with only an `email` and no `userId` are an edge case
|
||||||
|
(written without a per-account uniqueness check).
|
||||||
|
11. **Geoip timezone display.** Phase 3 shows an informational note that times
|
||||||
|
run in the **Kiosk's** timezone and uses the **browser-resolved** timezone
|
||||||
|
as a fallback; no geoip dependency (paid or external) was added. The
|
||||||
|
Portal still stores no timezone.
|
||||||
|
12. **ETag on the legacy endpoint.** The legacy `GET /api/v2/schedule` emits no
|
||||||
|
ETag and never returns 304, to preserve old behaviour.
|
||||||
|
13. **`systemNameStatus` on v1.** Not added; v1 is unchanged.
|
||||||
|
14. **Trim/case representation.** Names are trimmed; uniqueness is
|
||||||
|
case-insensitive; the stored casing is preserved as provided.
|
||||||
|
15. **Sync cadence.** Resolved in Phase 3: the Kiosk loads its on-disk cache on
|
||||||
|
boot, then pulls on boot, on a 5-minute timer, and on demand.
|
||||||
|
16. **Atomic cache file.** Resolved in Phase 3:
|
||||||
|
`~/.local/radiantwave/schedule_cache.json`, written atomically (temp file +
|
||||||
|
rename); a corrupt cache is ignored.
|
||||||
|
17. **`type` encoding for no schedule.** `type: null`; `daily`/`weekly` absent
|
||||||
|
or empty.
|
||||||
|
18. **Legacy alias.** `key` remains accepted as a `licenseKey` alias on the
|
||||||
|
legacy schedule endpoint.
|
||||||
|
19. **Kiosk local toggle.** `proclamation.schedule_enabled` in `config.yaml`,
|
||||||
|
default **on**, surfaced as System Settings → Schedule. Not synced. When
|
||||||
|
off, the engine plays local selections but the cache keeps updating. The
|
||||||
|
older `proclamation.schedule_active` flag is retained in config but is not
|
||||||
|
consulted by the Phase 3 engine.
|
||||||
|
20. **Copy semantics.** Copies are **independent, by value** snapshots: no link
|
||||||
|
is kept to the source, and editing the source later does not affect targets
|
||||||
|
(and vice versa). A copy replaces any existing target Schedule in full
|
||||||
|
(type and blocks) and requires explicit confirmation in the UI when a target
|
||||||
|
already has one. No "copy to all" shortcut and no scheduled/linked copies
|
||||||
|
(per the Phase 4 brief). The copied schedule is validated before commit; a
|
||||||
|
source schedule that fails validation is rejected rather than copied.
|
||||||
|
21. **Copy and Kiosk code.** Verified against the Phase 3 Kiosk engine/fixture
|
||||||
|
tests that a copied payload (new Block IDs, same type/times/audio/refs) is
|
||||||
|
parsed and evaluated identically to any other; **no Kiosk changes were
|
||||||
|
required**.
|
||||||
|
22. **Phase 3 migration down is lossy for new-model data.** The `up` migration
|
||||||
|
(`20260929130000_move_schedule_to_license_key`) is additive and safe. Its
|
||||||
|
`down` path is **reversible in structure but lossy in data**: the down SQL
|
||||||
|
drops `Schedule.licenseKeyId`/`type` and `Block.day`, then deletes
|
||||||
|
`Schedule` rows whose `userId IS NULL`. Any schedule created under the
|
||||||
|
per-License-Key model (which sets `licenseKeyId` and leaves `userId` NULL)
|
||||||
|
is therefore **deleted** by a down-migration. Consequences:
|
||||||
|
- Back up `Schedule`/`Block`/`BlockProclamations` before any down-migration.
|
||||||
|
- Rolling back past Phase 3 requires restoring that data from backup; it is
|
||||||
|
**not** a data-preserving rollback.
|
||||||
|
- Rollback guidance is in
|
||||||
|
[scheduler-overhaul-release-notes.md](scheduler-overhaul-release-notes.md).
|
||||||
|
23. **Kiosk UI/E2E on real hardware not run here.** The verification ran the
|
||||||
|
Portal and the Kiosk schedule logic (engine, client, cache) via the test
|
||||||
|
harness and real HTTP against a locally running Portal; the SDL Kiosk UI was
|
||||||
|
not driven interactively because the build environment has no display. See
|
||||||
|
the manual checklist in the release notes and the Phase 5 report.
|
||||||
|
24. **Unauthenticated Kiosk endpoints remain a known limitation** (restated from
|
||||||
|
item 6): the key-only schedule and system-name endpoints authenticate by
|
||||||
|
License Key alone, mitigated only by the 10/min/IP rate limit. Authentication
|
||||||
|
is out of scope for this overhaul.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
- 2026-09-29 — Initial contract created (Phase 0). Documents current state,
|
||||||
|
target time semantics, schedule data model, validation rules, endpoints
|
||||||
|
(`/api/v2/licenses/...`, additive license-validation fields), sync behavior,
|
||||||
|
System Name rules, dynamic audio, backward compatibility, touchpoints,
|
||||||
|
open items, and fixtures.
|
||||||
|
- 2026-09-29 — Phase 3. Schedule is now per License Key with daily/weekly and
|
||||||
|
`Block.day`; the shared `validateSchedule` is authoritative; the key-only
|
||||||
|
`GET /api/v2/licenses/{licenseKey}/schedule` (ETag/304) ships and the legacy
|
||||||
|
endpoint is extended additively; the Portal editor is per-system with a
|
||||||
|
browser-fallback timezone note; the Kiosk adds the conditional client,
|
||||||
|
atomic cache, schedule engine, and a System Settings On/Off toggle. Open
|
||||||
|
items 2, 5, 7, 8, 9, 11, 15, and 16 are updated to reflect the changes.
|
||||||
|
- 2026-09-29 — Phase 4. Schedule copy is implemented as a Portal-internal
|
||||||
|
action (`?/copy`) backed by a single-transaction service that copies by
|
||||||
|
value with fresh Block IDs (so target ETags change), preserving `dynamicAudio`
|
||||||
|
and Proclamation order, scoped to the caller's account, and validated before
|
||||||
|
commit. Endpoint (e) is finalized; open items 20 and 21 record the copy
|
||||||
|
semantics and the Kiosk no-change verification.
|
||||||
|
- 2026-09-29 — Phase 5 (final). End-to-end verification against a locally
|
||||||
|
running Portal on a fresh database: schedule create/type/copy services and
|
||||||
|
the Kiosk-facing HTTP endpoints (validation with/without `systemName`,
|
||||||
|
system-name PUT, key-only schedule GET with ETag/304/404, legacy endpoint
|
||||||
|
parity, cross-account rejection) all pass; old-client compatibility confirmed;
|
||||||
|
migrations verified up/down (the down path is lossy, see open item 22). Adds
|
||||||
|
open items 22–24 (lossy down-migration, unrun UI-on-hardware checks, and the
|
||||||
|
restated unauthenticated-endpoint limitation) and the release notes.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Scheduler Overhaul — Phase 5 Verification Results
|
||||||
|
|
||||||
|
Prepared during Phase 5 against a **locally running Portal on a fresh database**.
|
||||||
|
This records what was actually executed and observed. It is a companion to
|
||||||
|
`scheduler-overhaul-release-notes.md` (rollout steps) and
|
||||||
|
`schedule-api-contract.md` (the contract). Where a scenario could not be run
|
||||||
|
without real Kiosk hardware or an interactive display, the row is marked and the
|
||||||
|
equivalent automated coverage is cited.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
|
||||||
|
- Portal dev server (`npm run dev`) on `http://localhost:5173`, pointed at a
|
||||||
|
fresh scratch database (`rwportal_p5`) created and migrated for this phase.
|
||||||
|
- Kiosk schedule logic exercised through the C++ test harness
|
||||||
|
(`test_schedule_engine`) and the Kiosk API client's JSON parsing.
|
||||||
|
- No display/Xvfb available, so the SDL Kiosk UI was **not** driven
|
||||||
|
interactively; see the manual checklist in the release notes.
|
||||||
|
|
||||||
|
Legend: **PASS = executed**, **AUTO = verified by automated test** (not
|
||||||
|
interactively), **MANUAL = requires a human on hardware/display**.
|
||||||
|
|
||||||
|
| # | Scenario | Result | Evidence / notes |
|
||||||
|
| --- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| a | Kiosk shows AM/PM everywhere; System Settings contains Email, License Key, System Name, Schedule toggle, WiFi, Timezone, Date Format | AUTO | `test_date_format` (12h conversion), `test_settings_menu` (exactly the 7 entries incl. System Name + Schedule). UI rendering itself is MANUAL. |
|
||||||
|
| b | Validate a License Key, name the Kiosk, see the name read-only on the Portal | PASS + MANUAL | HTTP: validation without name → `200 unchanged`; with name → `200 ok`. Portal render is MANUAL (release-notes checklist). |
|
||||||
|
| c | Two systems in one account: duplicate name in any casing rejected and reverted on the Kiosk | PASS | HTTP: case-insensitive duplicate → validation `200 conflict`; `PUT` duplicate → `409 name_taken`; blank → `400 invalid_name`; unknown → `404 unknown_license`. Kiosk revert covered by `test_license_service`/`test_system_name`. |
|
||||||
|
| d | Rename a system on the Kiosk; its Schedule stays attached (License Key reference) | AUTO | Schedules are keyed by `licenseKeyId`; naming only touches `systemName`. Portal `schedule-copy`/service tests operate purely on License Key IDs. |
|
||||||
|
| e | Create a daily Schedule via the Portal picker; Kiosk fetches and applies it | PASS | Opt-in integration spec: daily schedule with 2 blocks created via the service; key-only GET returns it; captured live payload parsed and evaluated by the Kiosk (`SchedulePortalParityTest`). |
|
||||||
|
| f | Weekly: Block boundaries, adjacent (end 7:00 / start 7:00), midnight-crossing, Sunday→Monday spillover | PASS + AUTO | Integration spec: weekly sun crossing + adjacent mon blocks, Sunday spillover collision rejected. Engine tests: adjacency boundary, midnight self-wrap, Sunday→Monday spillover. |
|
||||||
|
| g | Gap plays local Proclamations; `dynamicAudio` true → dynamic, false → local | AUTO | Engine tests: gaps return no block; per-block `dynamicAudio` flag; `ScrollerPage::effectiveDynamicAudio` honored via playback wiring (build passes). |
|
||||||
|
| h | Schedule toggle Off stops schedule effects; On resumes | AUTO | `ScheduleManagerTest.DisabledReturnsNoBlock`; toggle stored in `config.yaml` (`schedule_enabled`, default on). |
|
||||||
|
| i | Schedules assigned to system A never affect system B | PASS | Integration spec: A daily, B weekly simultaneously; independent payloads. |
|
||||||
|
| j | Copy a Schedule to another named system; that Kiosk picks it up | PASS | Integration spec: copy A→second system yields daily with 2 blocks; independence confirmed; target ETag differs. Portal `schedule-copy.spec` covers overwrite/multi-target/rollback/new IDs. |
|
||||||
|
| k | Offline uses cached Schedule; corrupt cache ignored; timezone change re-evaluates | AUTO | `ScheduleCacheTest` (round-trip, corrupt ignored), `ScheduleManagerTest` (offline cache, 304 handled). Timezone is passed as wall-clock minutes (wall-clock means DST-safe). |
|
||||||
|
| l | Portal shows geoip/fallback timezone with kiosk-timezone note; manual minute entry alongside 5-minute options | AUTO | UI note is browser-fallback (no external dep); `TimeInput` tests cover 5-minute options and typed minutes. Visual render MANUAL. |
|
||||||
|
|
||||||
|
## Additional checks
|
||||||
|
|
||||||
|
| Check | Result | Notes |
|
||||||
|
| --------------------------------------------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Old-Kiosk compatibility (validate without `systemName`; legacy schedule endpoint) | PASS | `200 unchanged`; name never cleared; legacy `GET /api/v2/schedule` still returns `email` + `blocks`; modern vs legacy `version` equal. |
|
||||||
|
| Cross-account isolation | PASS | Legacy schedule with another account's key → `400`; copy rejects non-owned source/targets. |
|
||||||
|
| Conditional GET | PASS | Matching `If-None-Match` → `304`; stale ETag → `200`; unknown key → `404`. |
|
||||||
|
| Migration up | PASS | Applied cleanly to a fresh DB (29 migrations); fresh seed succeeded. |
|
||||||
|
| Migration down/up on realistic data | PASS (with caveat) | Phase 2 and Phase 3 down/up round-tripped structurally on a clone; **the Phase 3 down path is lossy** (deletes per-License-Key schedules). Documented in the contract (Open item 22) and release notes. |
|
||||||
|
| Portal `svelte-check` / ESLint / tests | PASS | 0 errors/warnings; 394 passed, 4 skipped (opt-in integration). |
|
||||||
|
| Kiosk build + tests | PASS | Full build; 21 test binaries pass. |
|
||||||
|
| Formatters | PASS | Portal Prettier clean except the pre-existing `.gitea/workflows/deploy.yaml` (unchanged from before this branch). Kiosk follows `.clang-format` (no binary available to run). |
|
||||||
|
|
||||||
|
## Not verified here (requires hardware/display)
|
||||||
|
|
||||||
|
The interactive SDL Kiosk UI (rendering, key handling, onboarding walkthrough)
|
||||||
|
was not driven; all Kiosk behavior above is covered by the engine/client/cache
|
||||||
|
tests and by parsing live Portal payloads. See the manual checklist in
|
||||||
|
`scheduler-overhaul-release-notes.md`.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Scheduler Overhaul — Release Notes
|
||||||
|
|
||||||
|
Status: prepared for rollout. These are **instructions only**; nothing here has
|
||||||
|
been executed. The contract lives at `docs/schedule-api-contract.md` (kept
|
||||||
|
byte-identical in both repos).
|
||||||
|
|
||||||
|
This document is kept **byte-identical** in both repositories:
|
||||||
|
|
||||||
|
- `radiantwave` (C++/SDL3 Kiosk app) — `docs/scheduler-overhaul-release-notes.md`
|
||||||
|
- `portal` (SvelteKit/Node) — `docs/scheduler-overhaul-release-notes.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
### Portal
|
||||||
|
|
||||||
|
- **Schedules are assigned to a License Key**, not a user. `Schedule` gained
|
||||||
|
`licenseKeyId` (unique, nullable) and `type` (`daily` | `weekly` | null);
|
||||||
|
`Block` gained `day` (null for daily, `mon`..`sun` for weekly) and its
|
||||||
|
`dynamicAudio` default became `true`.
|
||||||
|
- **New Kiosk endpoint** `GET /api/v2/licenses/{licenseKey}/schedule` returns the
|
||||||
|
target payload (`version`, `type`, `daily`, `weekly`, `blocks`) with an `ETag`
|
||||||
|
derived from `version`, supports conditional GET (`304`), and is `404` for an
|
||||||
|
unknown key.
|
||||||
|
- **License validation** (`POST /api/v2/license`) accepts an optional
|
||||||
|
`systemName` and returns an additive `systemNameStatus`
|
||||||
|
(`ok` | `unchanged` | `conflict` | `invalid`). Validation always succeeds; a
|
||||||
|
non-`ok` name status is advisory.
|
||||||
|
- **New Kiosk endpoint** `PUT /api/v2/licenses/{licenseKey}/system-name`
|
||||||
|
(`200` / `400 invalid_name` / `404 unknown_license` / `409 name_taken`,
|
||||||
|
idempotent).
|
||||||
|
- **System Name** is a human-readable label for a License Key. It is set only
|
||||||
|
by the Kiosk and shown read-only in the Portal (dashboard and admin lists),
|
||||||
|
falling back to displaying the License Key. There is no Portal rename path.
|
||||||
|
Names are unique per account, compared case-insensitively, trimmed, 1–40
|
||||||
|
characters.
|
||||||
|
- **Schedule editor** (protected user route `(protected)/(user)/schedule`):
|
||||||
|
a per-system picker labelled by System Name, a Daily/Weekly type selector with
|
||||||
|
confirmation when switching a populated schedule (daily→weekly prefills all
|
||||||
|
seven days; weekly→daily and clearing start empty), per-day weekly editing,
|
||||||
|
AM/PM `TimeInput` (5-minute suggestions plus manual minute entry), midnight
|
||||||
|
crossing labelled "ends next day", an on-by-default dynamic-audio toggle, gap
|
||||||
|
helper text, and an informational note that times run in the Kiosk's own
|
||||||
|
timezone (browser timezone fallback; no external GeoIP service).
|
||||||
|
- **Copy to…** copies one system's Schedule to one or more other systems in the
|
||||||
|
same account, by value, in a single database transaction (all or none). Target
|
||||||
|
Blocks get fresh IDs so target `version`/ETag changes; `dynamicAudio` and
|
||||||
|
Proclamation order are preserved. Overwriting a target that already has a
|
||||||
|
schedule requires explicit confirmation. Copies are independent.
|
||||||
|
- **OpenAPI** (`docs/openapi.yaml`) documents the new endpoints and payloads.
|
||||||
|
|
||||||
|
### Kiosk (`radiantwave`)
|
||||||
|
|
||||||
|
- **12-hour AM/PM** time display everywhere the Kiosk shows a time; storage and
|
||||||
|
parsing remain 24-hour `HH:MM`. The Date Format setting is unchanged.
|
||||||
|
- **System Settings** menu contains Email Address, License Key, **System Name**,
|
||||||
|
**Schedule** (on/off), WiFi Setup, Time Zone Setup, and Date Format. The list
|
||||||
|
is data-driven.
|
||||||
|
- **System Name** entry (trimmed, 1–40), sent on license validation and via
|
||||||
|
`PUT /api/v2/licenses/{key}/system-name`. Conflicts/invalid names are reverted
|
||||||
|
locally with a user-facing message; network failures keep the new name locally
|
||||||
|
and retry (pending-sync flag) on boot, on validation, and on a timer.
|
||||||
|
- **Schedule client**: conditional GET (`If-None-Match`) of
|
||||||
|
`GET /api/v2/licenses/{key}/schedule`, on boot, on network-up, and every
|
||||||
|
5 minutes. The last payload is cached to
|
||||||
|
`~/.local/radiantwave/schedule_cache.json` with an **atomic write** (temp file
|
||||||
|
- rename); a corrupt cache is ignored. Offline uses the cache; no cache and no
|
||||||
|
network behaves as schedules-off.
|
||||||
|
- **Schedule engine**: given the payload, the local wall-clock time in the
|
||||||
|
Kiosk's Timezone setting, and the weekday, picks the active Block. Half-open
|
||||||
|
`[start, end)` boundaries; midnight-crossing Blocks belong to their start day
|
||||||
|
(weekly Sunday spills into Monday); invalid Blocks are skipped with a warning
|
||||||
|
and treated as gaps. Active Block → its Proclamations; `dynamicAudio` true
|
||||||
|
forces dynamic audio, false uses the locally chosen audio; gaps / no schedule
|
||||||
|
/ toggle off use local selection exactly as before.
|
||||||
|
- **Schedule toggle** in System Settings (`proclamation.schedule_enabled`,
|
||||||
|
default on, Kiosk-local, not synced). Off takes no action but the cache keeps
|
||||||
|
updating.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration notes
|
||||||
|
|
||||||
|
- **Portal migrations are additive `up` migrations** and are safe to deploy
|
||||||
|
while old Kiosks keep running:
|
||||||
|
- `20260929120000_add_license_key_system_name` — adds
|
||||||
|
`LicenseKey.systemName` / `systemNameLower` and a unique index on
|
||||||
|
`(userId, systemNameLower)`.
|
||||||
|
- `20260929130000_move_schedule_to_license_key` — adds `Schedule.licenseKeyId`
|
||||||
|
/ `type` and `Block.day`, changes the `dynamicAudio` default to `true`,
|
||||||
|
makes the legacy `Schedule.userId` optional/non-unique, and backfills each
|
||||||
|
existing per-user Schedule to that account's **oldest** License Key as a
|
||||||
|
**daily** schedule. Schedules that cannot be mapped (no owning user/license)
|
||||||
|
are retained with `licenseKeyId NULL` and reported via a migration `NOTICE`
|
||||||
|
— they are never silently dropped.
|
||||||
|
- **Existing (per-user) schedules survive the `up` migration** and are mapped to
|
||||||
|
a License Key on the same account.
|
||||||
|
- **⚠ The `down` path is lossy for data created under the new model.** Back up
|
||||||
|
`Schedule`, `Block`, and `BlockProclamations` before any down-migration.
|
||||||
|
Rolling back past the Phase 3 migration deletes per-License-Key schedules (see
|
||||||
|
the contract's Open item 22).
|
||||||
|
- No Portal schema change affects Kiosks (the Kiosk stores nothing new on the
|
||||||
|
server beyond the System Name label and its schedule mapping).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended rollout order
|
||||||
|
|
||||||
|
Each step is independent; do not skip the verification gates. Do **not** tag,
|
||||||
|
push, or deploy as part of reading this document — these are instructions.
|
||||||
|
|
||||||
|
### Step 1 — Run Portal migrations and deploy the Portal
|
||||||
|
|
||||||
|
1. Back up the Portal database (at minimum the `Schedule`, `Block`,
|
||||||
|
`BlockProclamations`, and `LicenseKey` tables).
|
||||||
|
2. Apply migrations: `npm run deploy` (which runs `npx prisma migrate deploy`).
|
||||||
|
3. Confirm `npx prisma migrate status` reports the database is up to date and
|
||||||
|
review the migration logs for the unmappable-schedule `NOTICE`, if any.
|
||||||
|
4. Deploy the Portal build (the existing CI/deploy path).
|
||||||
|
|
||||||
|
**Rollback (Step 1):** redeploy the previous Portal build. Because the `up`
|
||||||
|
migrations are additive, the old Portal continues to work against the migrated
|
||||||
|
schema; no schema rollback is required to redeploy the old Portal. If a schema
|
||||||
|
rollback is unavoidable, restore from the Step-1 backup (the `down` path is
|
||||||
|
lossy — see Migration notes).
|
||||||
|
|
||||||
|
### Step 2 — Verify old Kiosks still work
|
||||||
|
|
||||||
|
1. Against the deployed Portal, confirm an **old Kiosk** (no `systemName`, using
|
||||||
|
the legacy endpoints) still:
|
||||||
|
- validates via `POST /api/v2/license` (no `systemName`) and receives `200`;
|
||||||
|
- reads `GET /api/v2/schedule?email=…&licenseKey=…` and still finds its
|
||||||
|
`blocks`;
|
||||||
|
- is unaffected by the new fields, and its stored System Name (if any) is
|
||||||
|
never cleared.
|
||||||
|
2. Confirm the Portal dashboard/admin show System Names read-only.
|
||||||
|
|
||||||
|
**Rollback (Step 2):** none needed — this step is read-only verification. If old
|
||||||
|
Kiosks misbehave, roll back the Portal (Step 1 rollback) before proceeding.
|
||||||
|
|
||||||
|
### Step 3 — Release the Kiosk build
|
||||||
|
|
||||||
|
1. Build and validate the Kiosk through the normal path: tag → CI → the
|
||||||
|
auto-update channel. (Tagging triggers production CI; do it only when ready.)
|
||||||
|
2. Confirm the released build reports the expected version and updates in place.
|
||||||
|
3. Spot-check a device after update: 12-hour times, System Settings contents,
|
||||||
|
schedule on/off, and that an assigned Schedule is fetched and applied.
|
||||||
|
|
||||||
|
**Rollback (Step 3):** use the existing auto-update rollback (re-publish or
|
||||||
|
pin the previous known-good Kiosk version). The Kiosk tolerates both old and new
|
||||||
|
Portal payloads, so a Portal rollback alone does not require a Kiosk rollback,
|
||||||
|
and vice versa.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
- Kiosk API endpoints are **unauthenticated** (key-only), mitigated by a
|
||||||
|
10 requests/min/IP rate limit. Authentication is out of scope for this
|
||||||
|
overhaul.
|
||||||
|
- The Phase 3 migration's `down` path is lossy for new-model schedules
|
||||||
|
(see Migration notes and contract Open item 22).
|
||||||
|
- The Portal does not offer a rename path for System Names by design.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "LicenseKey" ADD COLUMN "systemName" TEXT,
|
||||||
|
ADD COLUMN "systemNameLower" TEXT;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "LicenseKey_userId_systemNameLower_key" ON "LicenseKey"("userId", "systemNameLower");
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
-- Phase 3: move Schedule from per-user to per-License-Key, add weekly support.
|
||||||
|
|
||||||
|
-- AlterTable: Schedule gains licenseKeyId + type, userId becomes optional.
|
||||||
|
ALTER TABLE "Schedule" ADD COLUMN "licenseKeyId" TEXT,
|
||||||
|
ADD COLUMN "type" TEXT;
|
||||||
|
|
||||||
|
-- AlterTable: Block gains day; new Blocks default dynamicAudio true.
|
||||||
|
ALTER TABLE "Block" ADD COLUMN "day" TEXT;
|
||||||
|
ALTER TABLE "Block" ALTER COLUMN "dynamicAudio" SET DEFAULT true;
|
||||||
|
|
||||||
|
-- DropIndex: userId is no longer the schedule identity.
|
||||||
|
DROP INDEX "Schedule_userId_key";
|
||||||
|
|
||||||
|
-- AlterTable: legacy owner becomes optional and non-unique.
|
||||||
|
ALTER TABLE "Schedule" ALTER COLUMN "userId" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Schedule_licenseKeyId_key" ON "Schedule"("licenseKeyId");
|
||||||
|
CREATE INDEX "Schedule_userId_idx" ON "Schedule"("userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Schedule" ADD CONSTRAINT "Schedule_licenseKeyId_fkey" FOREIGN KEY ("licenseKeyId") REFERENCES "LicenseKey"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- Backfill: map each existing per-user Schedule to the account's oldest
|
||||||
|
-- License Key. Existing content was daily-only.
|
||||||
|
UPDATE "Schedule" s
|
||||||
|
SET "licenseKeyId" = lk."id",
|
||||||
|
"type" = COALESCE(s."type", 'daily')
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON ("userId") "id", "userId"
|
||||||
|
FROM "LicenseKey"
|
||||||
|
WHERE "userId" IS NOT NULL
|
||||||
|
ORDER BY "userId", "createdAt" ASC, "id" ASC
|
||||||
|
) lk
|
||||||
|
WHERE s."userId" = lk."userId"
|
||||||
|
AND s."licenseKeyId" IS NULL;
|
||||||
|
|
||||||
|
-- Report (do not silently drop) schedules that cannot be mapped to a key.
|
||||||
|
DO $$
|
||||||
|
DECLARE unmapped integer;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO unmapped FROM "Schedule" WHERE "licenseKeyId" IS NULL;
|
||||||
|
IF unmapped > 0 THEN
|
||||||
|
RAISE NOTICE 'Phase 3 migration: % Schedule row(s) could not be mapped to a License Key (no owning user/license); retained with licenseKeyId NULL.', unmapped;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
+35
-16
@@ -28,7 +28,7 @@ model User {
|
|||||||
profiles Profile[]
|
profiles Profile[]
|
||||||
licenseKeys LicenseKey[]
|
licenseKeys LicenseKey[]
|
||||||
userProclamations UserProclamations[]
|
userProclamations UserProclamations[]
|
||||||
schedule Schedule?
|
schedules Schedule[]
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
@@ -205,10 +205,18 @@ model LicenseKey {
|
|||||||
userId String?
|
userId String?
|
||||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||||
email String?
|
email String?
|
||||||
|
schedule Schedule?
|
||||||
|
// Human-readable label set only by the Kiosk (e.g. "Living Room"). Trimmed,
|
||||||
|
// 1-40 chars. Never a reference or foreign key; the License Key is the only
|
||||||
|
// identifier. `systemNameLower` is a case-folded copy used to enforce
|
||||||
|
// per-account, case-insensitive uniqueness when non-null.
|
||||||
|
systemName String?
|
||||||
|
systemNameLower String?
|
||||||
// Legacy subscription level. Retained in the database but unmanaged; hidden
|
// Legacy subscription level. Retained in the database but unmanaged; hidden
|
||||||
// from the client and left NULL.
|
// from the client and left NULL.
|
||||||
level String? @ignore
|
level String? @ignore
|
||||||
|
|
||||||
|
@@unique([userId, systemNameLower])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
@@ -256,31 +264,42 @@ model AuditLog {
|
|||||||
@@map("AuditLog")
|
@@map("AuditLog")
|
||||||
}
|
}
|
||||||
|
|
||||||
// A user's daily listening schedule. Exactly one per user (enforced by the
|
// A listening schedule assigned to one License Key (at most one per key).
|
||||||
// unique userId); contains 0-4 time Blocks. Times are wall-clock minutes from
|
// `type` is "daily" | "weekly" | null (null = no schedule). Times are
|
||||||
// midnight (0-1440), interpreted by the kiosk in the user's configured
|
// wall-clock minutes from midnight (0-1440), interpreted by the kiosk in its
|
||||||
// timezone. The portal never stores a timezone.
|
// own configured timezone. The portal never stores a timezone.
|
||||||
|
//
|
||||||
|
// `userId` is a legacy owner retained while per-user schedules are migrated to
|
||||||
|
// per-license-key; new schedules set `licenseKeyId` and leave `userId` null.
|
||||||
model Schedule {
|
model Schedule {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @unique
|
licenseKeyId String? @unique
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
licenseKey LicenseKey? @relation(fields: [licenseKeyId], references: [id], onDelete: Cascade)
|
||||||
blocks Block[]
|
userId String?
|
||||||
createdAt DateTime @default(now())
|
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
updatedAt DateTime @updatedAt
|
type String?
|
||||||
|
blocks Block[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// A single non-overlapping time range within a Schedule, plus the ordered set
|
// A single time range within a Schedule, plus the ordered set of selected
|
||||||
// of selected Proclamations to play during it. Overnight ranges are disallowed
|
// Proclamations to play during it. `day` is null for daily schedules and
|
||||||
// in v1, so startMinutes is always less than endMinutes.
|
// "mon".."sun" for weekly schedules. Overnight (midnight-crossing) ranges are
|
||||||
|
// valid: an end clock time before the start belongs to the start day and spills
|
||||||
|
// into the next day.
|
||||||
model Block {
|
model Block {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
scheduleId String
|
scheduleId String
|
||||||
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
schedule Schedule @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
|
||||||
|
day String?
|
||||||
startMinutes Int
|
startMinutes Int
|
||||||
endMinutes Int
|
endMinutes Int
|
||||||
// When true, the kiosk enables dynamic audio for the duration of this block,
|
// When true, the kiosk enables dynamic audio for the duration of this block,
|
||||||
// overriding any audio selections made on the device.
|
// overriding any audio selections made on the device. Portal default: true.
|
||||||
dynamicAudio Boolean @default(false)
|
dynamicAudio Boolean @default(true)
|
||||||
proclamations BlockProclamations[]
|
proclamations BlockProclamations[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
// Exercises the Kiosk-facing HTTP API exactly as the Kiosk client does.
|
||||||
|
// Scenarios: b (validate + name), c (duplicate name conflict), k (ETag/304),
|
||||||
|
// old-client backward compatibility.
|
||||||
|
const BASE = process.env.P5_BASE ?? 'http://localhost:5173';
|
||||||
|
|
||||||
|
const KA1 = 'ALCE-AAAA-AAAA-0001';
|
||||||
|
const KA2 = 'ALCE-AAAA-AAAA-0002';
|
||||||
|
const KB1 = 'BOBB-BBBB-BBBB-0001';
|
||||||
|
const EMAIL = 'p5-alice@example.com';
|
||||||
|
|
||||||
|
const results: { id: string; pass: boolean; note: string }[] = [];
|
||||||
|
function rec(id: string, pass: boolean, note: string) {
|
||||||
|
results.push({ id, pass, note });
|
||||||
|
console.log(`${pass ? 'PASS' : 'FAIL'} ${id} ${note}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function post(path: string, body: unknown) {
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json().catch(() => null), headers: res.headers };
|
||||||
|
}
|
||||||
|
async function put(path: string, body: unknown) {
|
||||||
|
const res = await fetch(BASE + path, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
return { status: res.status, body: await res.json().catch(() => null) };
|
||||||
|
}
|
||||||
|
async function get(path: string, headers: Record<string, string> = {}) {
|
||||||
|
const res = await fetch(BASE + path, { headers });
|
||||||
|
return { status: res.status, body: await res.json().catch(() => null), headers: res.headers };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// (b) validate without a name first (old-client behavior), then set a name.
|
||||||
|
const validateNoName = await post('/api/v2/license', {
|
||||||
|
license_key: KA1,
|
||||||
|
email_address: EMAIL
|
||||||
|
});
|
||||||
|
rec(
|
||||||
|
'b1',
|
||||||
|
validateNoName.status === 200 && validateNoName.body?.systemNameStatus === 'unchanged',
|
||||||
|
`validation without systemName -> ${validateNoName.status}, status=${validateNoName.body?.systemNameStatus}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const setName = await post('/api/v2/license', {
|
||||||
|
license_key: KA1,
|
||||||
|
email_address: EMAIL,
|
||||||
|
systemName: 'Living Room'
|
||||||
|
});
|
||||||
|
rec(
|
||||||
|
'b2',
|
||||||
|
setName.status === 200 && setName.body?.systemNameStatus === 'ok',
|
||||||
|
`name a Kiosk -> ${setName.status}, status=${setName.body?.systemNameStatus}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// (c) duplicate name, different casing, same account -> conflict + not cleared.
|
||||||
|
const dup = await post('/api/v2/license', {
|
||||||
|
license_key: KA2,
|
||||||
|
email_address: EMAIL,
|
||||||
|
systemName: 'living room'
|
||||||
|
});
|
||||||
|
rec(
|
||||||
|
'c1',
|
||||||
|
dup.status === 200 && dup.body?.systemNameStatus === 'conflict',
|
||||||
|
`case-insensitive duplicate -> status=${dup.body?.systemNameStatus} (validation still ${dup.status})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Confirm the original name survived and KA2 has no name.
|
||||||
|
const after = await get('/api/v2/licenses/' + KA1 + '/schedule');
|
||||||
|
rec('c2', after.status === 200, `KA1 schedule GET after conflict -> ${after.status}`);
|
||||||
|
|
||||||
|
// (c) PUT system-name endpoint: 409 for a taken name.
|
||||||
|
const putDup = await put('/api/v2/licenses/' + KA2 + '/system-name', {
|
||||||
|
systemName: 'LIVING ROOM'
|
||||||
|
});
|
||||||
|
rec(
|
||||||
|
'c3',
|
||||||
|
putDup.status === 409 && putDup.body?.message === 'name_taken',
|
||||||
|
`PUT duplicate -> ${putDup.status} ${putDup.body?.message}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const putOk = await put('/api/v2/licenses/' + KA2 + '/system-name', { systemName: 'Kitchen' });
|
||||||
|
rec('c4', putOk.status === 200, `PUT unique name -> ${putOk.status}`);
|
||||||
|
|
||||||
|
const putInvalid = await put('/api/v2/licenses/' + KA2 + '/system-name', { systemName: ' ' });
|
||||||
|
rec(
|
||||||
|
'c5',
|
||||||
|
putInvalid.status === 400 && putInvalid.body?.message === 'invalid_name',
|
||||||
|
`PUT blank -> ${putInvalid.status} ${putInvalid.body?.message}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const putUnknown = await put('/api/v2/licenses/ZZZZ-ZZZZ-ZZZZ-ZZZZ/system-name', {
|
||||||
|
systemName: 'X'
|
||||||
|
});
|
||||||
|
rec(
|
||||||
|
'c6',
|
||||||
|
putUnknown.status === 404 && putUnknown.body?.message === 'unknown_license',
|
||||||
|
`PUT unknown license -> ${putUnknown.status} ${putUnknown.body?.message}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// (k) schedule GET parity between the legacy endpoint and the new key-only endpoint.
|
||||||
|
const modern = await get('/api/v2/licenses/' + KA1 + '/schedule');
|
||||||
|
const legacy = await get(
|
||||||
|
'/api/v2/schedule?email=' + encodeURIComponent(EMAIL) + '&licenseKey=' + KA1
|
||||||
|
);
|
||||||
|
rec(
|
||||||
|
'k1',
|
||||||
|
modern.status === 200 && legacy.status === 200 && modern.body?.version === legacy.body?.version,
|
||||||
|
`modern vs legacy version equal=${modern.body?.version === legacy.body?.version}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// (k) conditional GET: matching If-None-Match -> 304.
|
||||||
|
const etag = modern.headers.get('etag') ?? '';
|
||||||
|
const notModified = await get('/api/v2/licenses/' + KA1 + '/schedule', { 'if-none-match': etag });
|
||||||
|
rec('k2', notModified.status === 304, `conditional GET with ETag -> ${notModified.status}`);
|
||||||
|
|
||||||
|
const stale = await get('/api/v2/licenses/' + KA1 + '/schedule', { 'if-none-match': '"stale"' });
|
||||||
|
rec('k3', stale.status === 200, `conditional GET with stale ETag -> ${stale.status}`);
|
||||||
|
|
||||||
|
// Unknown license -> 404.
|
||||||
|
const missing = await get('/api/v2/licenses/ZZZZ-ZZZZ-ZZZZ-ZZZZ/schedule');
|
||||||
|
rec('k4', missing.status === 404, `unknown license schedule GET -> ${missing.status}`);
|
||||||
|
|
||||||
|
// Old-client validation (no systemName field at all) never clears the name.
|
||||||
|
const validateAgain = await post('/api/v2/license', { license_key: KA1, email_address: EMAIL });
|
||||||
|
const checkName = await put('/api/v2/licenses/' + KA1 + '/system-name', {
|
||||||
|
systemName: 'Living Room'
|
||||||
|
});
|
||||||
|
rec(
|
||||||
|
'old1',
|
||||||
|
validateAgain.status === 200 &&
|
||||||
|
validateAgain.body?.systemNameStatus === 'unchanged' &&
|
||||||
|
checkName.status === 200,
|
||||||
|
`old-client validate stays unchanged; name still present`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Old-client list endpoint shape: blocks array still present.
|
||||||
|
rec(
|
||||||
|
'old2',
|
||||||
|
legacy.status === 200 && Array.isArray(legacy.body?.blocks),
|
||||||
|
`legacy endpoint still returns email+blocks (${legacy.body?.blocks?.length} blocks)`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cross-account: Bob's key with Alice's email must fail.
|
||||||
|
const cross = await get(
|
||||||
|
'/api/v2/schedule?email=' + encodeURIComponent(EMAIL) + '&licenseKey=' + KB1
|
||||||
|
);
|
||||||
|
rec('auth1', cross.status === 400, `cross-account legacy schedule -> ${cross.status}`);
|
||||||
|
|
||||||
|
console.log('\nP5_HTTP_RESULTS ' + JSON.stringify(results));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error('RUNNER ERROR', e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import pg from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient } from '../src/lib/generated/prisma/client';
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
|
||||||
|
|
||||||
|
// A handful of real Proclamations so schedule Blocks have valid references.
|
||||||
|
async function main() {
|
||||||
|
const category = await prisma.proclamationCategory.findFirst({ orderBy: { id: 'asc' } });
|
||||||
|
const group = await prisma.proclamationGroup.findFirst({ orderBy: { id: 'asc' } });
|
||||||
|
const freq = await prisma.solfeggioFrequency.findFirst({ orderBy: { id: 'asc' } });
|
||||||
|
if (!category || !group || !freq) throw new Error('seed missing catalog');
|
||||||
|
|
||||||
|
const names = ['P5 Dawn', 'P5 Dusk', 'P5 Noon', 'P5 Night'];
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const existing = await prisma.proclamation.findUnique({ where: { name } });
|
||||||
|
if (existing) {
|
||||||
|
ids.push(existing.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const created = await prisma.proclamation.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
content: `<p>${name} content</p>`,
|
||||||
|
categoryId: category.id,
|
||||||
|
groupId: group.id,
|
||||||
|
solfeggioFrequencyId: freq.id
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ids.push(created.id);
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(ids));
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (e) => {
|
||||||
|
console.error(e);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import pg from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient } from '../src/lib/generated/prisma/client';
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
// Two accounts, each with two License Keys, for scenario coverage:
|
||||||
|
// - acct A: keys KA1, KA2 (same account -> name uniqueness + copy)
|
||||||
|
// - acct B: key KB1 (cross-account isolation)
|
||||||
|
// Names are left NULL; the Kiosk sets them via the API.
|
||||||
|
const accounts = [
|
||||||
|
{ email: 'p5-alice@example.com', key: 'ALCE-AAAA-AAAA-0001' },
|
||||||
|
{ email: 'p5-alice@example.com', key: 'ALCE-AAAA-AAAA-0002' },
|
||||||
|
{ email: 'p5-bob@example.com', key: 'BOBB-BBBB-BBBB-0001' }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function ensureUser(email) {
|
||||||
|
let user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!user) {
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: { id: crypto.randomUUID(), name: email.split('@')[0], email, emailVerified: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
for (const { email, key } of accounts) {
|
||||||
|
const user = await ensureUser(email);
|
||||||
|
const existing = await prisma.licenseKey.findUnique({ where: { key } });
|
||||||
|
if (!existing) {
|
||||||
|
await prisma.licenseKey.create({ data: { key, active: true, userId: user.id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('P5 test data ready:');
|
||||||
|
for (const { email, key } of accounts) console.log(` ${email} -> ${key}`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(async (e) => {
|
||||||
|
console.error(e);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import pg from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import { PrismaClient } from '../src/lib/generated/prisma/client';
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
|
||||||
|
const schedules = await prisma.schedule.findMany({ include: { blocks: true } });
|
||||||
|
const keys = await prisma.licenseKey.findMany({ select: { key: true, systemName: true } });
|
||||||
|
console.log(
|
||||||
|
JSON.stringify({
|
||||||
|
scheduleCount: schedules.length,
|
||||||
|
blockCount: schedules.reduce((n, s) => n + s.blocks.length, 0),
|
||||||
|
types: schedules.map((s) => s.type),
|
||||||
|
names: keys
|
||||||
|
.filter((k) => k.systemName)
|
||||||
|
.map((k) => k.systemName)
|
||||||
|
.sort()
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
fiveMinuteOptions,
|
||||||
|
formatTime,
|
||||||
|
hmToMinutes,
|
||||||
|
parseTimeInput,
|
||||||
|
toHm
|
||||||
|
} from '$lib/schedule-utils';
|
||||||
|
|
||||||
|
// A reusable 12-hour clock input. Displays and accepts AM/PM times, offers
|
||||||
|
// the 5-minute options as suggestions, and allows typing any minute 00-59.
|
||||||
|
// Its value model is a 24-hour "HH:MM" string.
|
||||||
|
let {
|
||||||
|
value = '00:00',
|
||||||
|
label,
|
||||||
|
onchange,
|
||||||
|
onvalidity,
|
||||||
|
required = false,
|
||||||
|
disabled = false
|
||||||
|
}: {
|
||||||
|
value?: string;
|
||||||
|
label?: string;
|
||||||
|
onchange?: (value: string) => void;
|
||||||
|
onvalidity?: (valid: boolean) => void;
|
||||||
|
required?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const inputId = $props.id();
|
||||||
|
const options = fiveMinuteOptions();
|
||||||
|
|
||||||
|
// While the user is typing we show their raw text (`editing`); otherwise we
|
||||||
|
// show the canonical 12-hour label derived from the 24-hour value.
|
||||||
|
let editing = $state<string | null>(null);
|
||||||
|
const valueMinutes = $derived(hmToMinutes(value) ?? 0);
|
||||||
|
const display = $derived(editing ?? formatTime(valueMinutes));
|
||||||
|
const error = $derived.by(() => {
|
||||||
|
if (editing === null) return '';
|
||||||
|
const result = parseTimeInput(editing);
|
||||||
|
return result.ok ? '' : result.error;
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleInput(event: Event) {
|
||||||
|
const raw = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
editing = raw;
|
||||||
|
const result = parseTimeInput(raw);
|
||||||
|
if (result.ok) {
|
||||||
|
onvalidity?.(true);
|
||||||
|
onchange?.(toHm(result.minutes));
|
||||||
|
} else {
|
||||||
|
onvalidity?.(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBlur() {
|
||||||
|
if (editing === null) return;
|
||||||
|
const result = parseTimeInput(editing);
|
||||||
|
if (result.ok) {
|
||||||
|
// Drop back to the canonical label derived from the value.
|
||||||
|
editing = null;
|
||||||
|
onvalidity?.(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus(event: FocusEvent) {
|
||||||
|
editing = (event.currentTarget as HTMLInputElement).value;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
{#if label}
|
||||||
|
<label for={inputId} class="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
<input
|
||||||
|
id={inputId}
|
||||||
|
type="text"
|
||||||
|
inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
class="rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-indigo-500 focus:outline-none disabled:opacity-50 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100"
|
||||||
|
list={`${inputId}-options`}
|
||||||
|
{required}
|
||||||
|
{disabled}
|
||||||
|
aria-label={label}
|
||||||
|
aria-invalid={error ? 'true' : undefined}
|
||||||
|
aria-describedby={error ? `${inputId}-error` : undefined}
|
||||||
|
value={display}
|
||||||
|
oninput={handleInput}
|
||||||
|
onfocus={handleFocus}
|
||||||
|
onblur={handleBlur}
|
||||||
|
/>
|
||||||
|
<datalist id={`${inputId}-options`}>
|
||||||
|
{#each options as minutes (minutes)}
|
||||||
|
<option value={formatTime(minutes)}></option>
|
||||||
|
{/each}
|
||||||
|
</datalist>
|
||||||
|
{#if error}
|
||||||
|
<p id={`${inputId}-error`} role="alert" class="text-sm text-red-600 dark:text-red-400">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import TimeInput from './TimeInput.svelte';
|
||||||
|
|
||||||
|
function mount(props: Record<string, unknown> = {}) {
|
||||||
|
return render(TimeInput, { value: '07:05', label: 'Time', ...props });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function type(text: string) {
|
||||||
|
const input = (await page.getByRole('combobox', { name: 'Time' }).element()) as HTMLInputElement;
|
||||||
|
input.value = text;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TimeInput', () => {
|
||||||
|
it('displays the 24-hour value as a 12-hour label', async () => {
|
||||||
|
mount({ value: '21:05' });
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('combobox', { name: 'Time' })).toHaveValue('9:05 PM');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onchange with a 24-hour value for tolerant input', async () => {
|
||||||
|
const onchange = vi.fn();
|
||||||
|
mount({ onchange });
|
||||||
|
|
||||||
|
await type('7:05 pm');
|
||||||
|
|
||||||
|
expect(onchange).toHaveBeenCalledWith('19:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts typed minutes that are not 5-minute steps', async () => {
|
||||||
|
const onchange = vi.fn();
|
||||||
|
mount({ value: '00:00', onchange });
|
||||||
|
|
||||||
|
await type('7:07');
|
||||||
|
|
||||||
|
expect(onchange).toHaveBeenCalledWith('07:07');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts an hour with no minutes', async () => {
|
||||||
|
const onchange = vi.fn();
|
||||||
|
mount({ onchange });
|
||||||
|
|
||||||
|
await type('7pm');
|
||||||
|
|
||||||
|
expect(onchange).toHaveBeenCalledWith('19:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows clear feedback and reports invalid input', async () => {
|
||||||
|
const onvalidity = vi.fn();
|
||||||
|
mount({ onvalidity });
|
||||||
|
|
||||||
|
await type('13:00 PM');
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||||
|
expect(onvalidity).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers the 5-minute clock options', async () => {
|
||||||
|
mount();
|
||||||
|
|
||||||
|
const options = document.querySelectorAll('datalist option');
|
||||||
|
expect(options).toHaveLength((24 * 60) / 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,11 +2,13 @@
|
|||||||
import { enhance } from '$app/forms';
|
import { enhance } from '$app/forms';
|
||||||
import type { SubmitFunction } from '@sveltejs/kit';
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
import ProclamationBrowser from './ProclamationBrowser.svelte';
|
import ProclamationBrowser from './ProclamationBrowser.svelte';
|
||||||
|
import TimeInput from '$lib/components/TimeInput.svelte';
|
||||||
import {
|
import {
|
||||||
MINUTE_STEP,
|
|
||||||
formatTime,
|
formatTime,
|
||||||
|
hmToMinutes,
|
||||||
isOvernight,
|
isOvernight,
|
||||||
validateBlock,
|
toHm,
|
||||||
|
validateBlockAgainstSchedule,
|
||||||
type BlockSibling
|
type BlockSibling
|
||||||
} from '$lib/schedule-utils';
|
} from '$lib/schedule-utils';
|
||||||
|
|
||||||
@@ -23,10 +25,13 @@
|
|||||||
open = false,
|
open = false,
|
||||||
mode = 'create',
|
mode = 'create',
|
||||||
blockId,
|
blockId,
|
||||||
|
day = null,
|
||||||
|
licenseKeyId = '',
|
||||||
initialStartMinutes = 9 * 60,
|
initialStartMinutes = 9 * 60,
|
||||||
initialEndMinutes = 10 * 60,
|
initialEndMinutes = 10 * 60,
|
||||||
initialProclamationIds = [],
|
initialProclamationIds = [],
|
||||||
initialDynamicAudio = false,
|
initialDynamicAudio = true,
|
||||||
|
scheduleType = 'daily' as 'daily' | 'weekly' | null,
|
||||||
categories = [],
|
categories = [],
|
||||||
groups = [],
|
groups = [],
|
||||||
proclamations = [],
|
proclamations = [],
|
||||||
@@ -40,10 +45,13 @@
|
|||||||
open?: boolean;
|
open?: boolean;
|
||||||
mode?: 'create' | 'edit';
|
mode?: 'create' | 'edit';
|
||||||
blockId?: string;
|
blockId?: string;
|
||||||
|
day?: string | null;
|
||||||
|
licenseKeyId?: string;
|
||||||
initialStartMinutes?: number;
|
initialStartMinutes?: number;
|
||||||
initialEndMinutes?: number;
|
initialEndMinutes?: number;
|
||||||
initialProclamationIds?: string[];
|
initialProclamationIds?: string[];
|
||||||
initialDynamicAudio?: boolean;
|
initialDynamicAudio?: boolean;
|
||||||
|
scheduleType?: 'daily' | 'weekly' | null;
|
||||||
categories?: BrowserCategory[];
|
categories?: BrowserCategory[];
|
||||||
groups?: BrowserGroup[];
|
groups?: BrowserGroup[];
|
||||||
proclamations?: BrowserProclamation[];
|
proclamations?: BrowserProclamation[];
|
||||||
@@ -56,58 +64,38 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let step = $state<'details' | 'browse'>('details');
|
let step = $state<'details' | 'browse'>('details');
|
||||||
let startHour = $state('9');
|
// Seeded from the props in the effect below whenever the modal opens.
|
||||||
let startMinute = $state('0');
|
let startMinutes = $state(9 * 60);
|
||||||
let startPeriod = $state<'AM' | 'PM'>('AM');
|
let endMinutes = $state(10 * 60);
|
||||||
let endHour = $state('10');
|
let startValid = $state(true);
|
||||||
let endMinute = $state('0');
|
let endValid = $state(true);
|
||||||
let endPeriod = $state<'AM' | 'PM'>('AM');
|
|
||||||
let selectedIds = $state<string[]>([]);
|
let selectedIds = $state<string[]>([]);
|
||||||
let dynamicAudio = $state(false);
|
let dynamicAudio = $state(true);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
const hourOptions = Array.from({ length: 12 }, (_, i) => String(i + 1));
|
function setStart(hm: string) {
|
||||||
const periodOptions: ('AM' | 'PM')[] = ['AM', 'PM'];
|
const minutes = hmToMinutes(hm);
|
||||||
|
if (minutes !== null) startMinutes = minutes;
|
||||||
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' } {
|
function setEnd(hm: string) {
|
||||||
const hours24 = Math.floor(totalMinutes / 60) % 24;
|
const minutes = hmToMinutes(hm);
|
||||||
return {
|
if (minutes !== null) endMinutes = minutes;
|
||||||
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.
|
// Re-seed the fields whenever the modal is (re)opened.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const start = to12Hour(initialStartMinutes);
|
startMinutes = initialStartMinutes;
|
||||||
startHour = String(start.hour);
|
endMinutes = initialEndMinutes;
|
||||||
startMinute = String(start.minute);
|
startValid = true;
|
||||||
startPeriod = start.period;
|
endValid = true;
|
||||||
const end = to12Hour(initialEndMinutes);
|
|
||||||
endHour = String(end.hour);
|
|
||||||
endMinute = String(end.minute);
|
|
||||||
endPeriod = end.period;
|
|
||||||
selectedIds = [...initialProclamationIds];
|
selectedIds = [...initialProclamationIds];
|
||||||
dynamicAudio = initialDynamicAudio;
|
dynamicAudio = initialDynamicAudio;
|
||||||
error = '';
|
error = '';
|
||||||
step = 'details';
|
step = 'details';
|
||||||
});
|
});
|
||||||
|
|
||||||
const startMinutes = $derived(to24Hour(startHour, startMinute, startPeriod));
|
|
||||||
const endMinutes = $derived(to24Hour(endHour, endMinute, endPeriod));
|
|
||||||
const overnight = $derived(
|
const overnight = $derived(
|
||||||
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
|
startMinutes !== endMinutes && isOvernight({ startMinutes, endMinutes })
|
||||||
);
|
);
|
||||||
@@ -122,10 +110,12 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function validate(): string | null {
|
function validate(): string | null {
|
||||||
const result = validateBlock(
|
if (!startValid || !endValid) return 'Enter a valid start and end time.';
|
||||||
{ startMinutes, endMinutes, proclamationIds: selectedIds },
|
if (scheduleType === null) return 'Set a schedule type first.';
|
||||||
siblings,
|
const result = validateBlockAgainstSchedule(
|
||||||
{ isCreate: mode === 'create' }
|
scheduleType,
|
||||||
|
{ day, startMinutes, endMinutes, proclamationIds: selectedIds },
|
||||||
|
siblings
|
||||||
);
|
);
|
||||||
return result.ok ? null : result.error;
|
return result.ok ? null : result.error;
|
||||||
}
|
}
|
||||||
@@ -210,6 +200,10 @@
|
|||||||
{#if mode === 'edit' && blockId}
|
{#if mode === 'edit' && blockId}
|
||||||
<input type="hidden" name="id" value={blockId} />
|
<input type="hidden" name="id" value={blockId} />
|
||||||
{/if}
|
{/if}
|
||||||
|
<input type="hidden" name="licenseKeyId" value={licenseKeyId} />
|
||||||
|
{#if day}
|
||||||
|
<input type="hidden" name="day" value={day} />
|
||||||
|
{/if}
|
||||||
<input type="hidden" name="startMinutes" value={startMinutes} />
|
<input type="hidden" name="startMinutes" value={startMinutes} />
|
||||||
<input type="hidden" name="endMinutes" value={endMinutes} />
|
<input type="hidden" name="endMinutes" value={endMinutes} />
|
||||||
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
|
<input type="hidden" name="proclamationIds" value={JSON.stringify(selectedIds)} />
|
||||||
@@ -228,81 +222,19 @@
|
|||||||
{#if step === 'details'}
|
{#if step === 'details'}
|
||||||
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
|
<div class="grid gap-6 md:grid-cols-[minmax(0,1fr)_16rem]">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div>
|
<TimeInput
|
||||||
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
label="Start time"
|
||||||
>Start time</span
|
value={toHm(startMinutes)}
|
||||||
>
|
onchange={setStart}
|
||||||
<div class="flex items-center gap-2">
|
onvalidity={(valid) => (startValid = valid)}
|
||||||
<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>
|
<TimeInput
|
||||||
<span class="mb-1 block text-sm font-medium text-slate-700 dark:text-slate-300"
|
label="End time"
|
||||||
>End time</span
|
value={toHm(endMinutes)}
|
||||||
>
|
onchange={setEnd}
|
||||||
<div class="flex items-center gap-2">
|
onvalidity={(valid) => (endValid = valid)}
|
||||||
<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>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
@@ -359,7 +291,7 @@
|
|||||||
<h3 class="text-sm font-semibold text-slate-800 dark:text-slate-200">Review</h3>
|
<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">
|
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||||
{formatTime(startMinutes)} – {formatTime(endMinutes)}{overnight
|
{formatTime(startMinutes)} – {formatTime(endMinutes)}{overnight
|
||||||
? ' (next day)'
|
? ' (ends next day)'
|
||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
{#if selectedTitles.length === 0}
|
{#if selectedTitles.length === 0}
|
||||||
|
|||||||
@@ -46,53 +46,50 @@ function submit() {
|
|||||||
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
|
return submitRef.current({ formData: new FormData(form), cancel: () => {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function typeTime(name: string, text: string) {
|
||||||
|
const input = (await page.getByRole('combobox', { name }).element()) as HTMLInputElement;
|
||||||
|
input.value = text;
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
describe('BlockModal', () => {
|
describe('BlockModal', () => {
|
||||||
it('uses separate hour and minute selects for start and end', async () => {
|
it('uses a 12-hour TimeInput for start and end', async () => {
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
await expect.element(page.getByRole('combobox', { name: /^Start hour$/ })).toBeInTheDocument();
|
await expect.element(page.getByRole('combobox', { name: 'Start time' })).toBeInTheDocument();
|
||||||
await expect
|
await expect.element(page.getByRole('combobox', { name: 'End time' })).toBeInTheDocument();
|
||||||
.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 () => {
|
it('pre-fills the fields from the initial times', async () => {
|
||||||
mount({ initialStartMinutes: 7 * 60 + 30, initialEndMinutes: 9 * 60 + 15 });
|
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 time' })).toHaveValue('7:30 AM');
|
||||||
await expect.element(page.getByRole('combobox', { name: /^Start minute$/ })).toHaveValue('30');
|
await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('9:15 AM');
|
||||||
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 () => {
|
it('shows a 12-hour clock with AM/PM', async () => {
|
||||||
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 0 });
|
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 time' })).toHaveValue('9:00 PM');
|
||||||
await expect.element(page.getByRole('combobox', { name: /^Start period$/ })).toHaveValue('PM');
|
await expect.element(page.getByRole('combobox', { name: 'End time' })).toHaveValue('12:00 AM');
|
||||||
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 () => {
|
it('recomputes the time from typed input', async () => {
|
||||||
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
|
mount({ initialStartMinutes: 9 * 60, initialEndMinutes: 10 * 60 });
|
||||||
|
|
||||||
const period = (await page
|
await typeTime('End time', '10:00 PM');
|
||||||
.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();
|
await expect.element(page.getByText('9:00 AM – 10:00 PM')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows validation feedback for unparseable input', async () => {
|
||||||
|
mount();
|
||||||
|
|
||||||
|
await typeTime('Start time', '25:00');
|
||||||
|
|
||||||
|
await expect.element(page.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the review times and selected proclamation titles', async () => {
|
it('shows the review times and selected proclamation titles', async () => {
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
@@ -103,7 +100,7 @@ describe('BlockModal', () => {
|
|||||||
it('marks an overnight block in the review', async () => {
|
it('marks an overnight block in the review', async () => {
|
||||||
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 9 * 60 });
|
mount({ initialStartMinutes: 21 * 60, initialEndMinutes: 9 * 60 });
|
||||||
|
|
||||||
await expect.element(page.getByText('9:00 PM – 9:00 AM (next day)')).toBeInTheDocument();
|
await expect.element(page.getByText('9:00 PM – 9:00 AM (ends next day)')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an overlapping time range with the specific error', async () => {
|
it('rejects an overlapping time range with the specific error', async () => {
|
||||||
@@ -113,7 +110,6 @@ describe('BlockModal', () => {
|
|||||||
initialEndMinutes: 9 * 60
|
initialEndMinutes: 9 * 60
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
|
|
||||||
submit();
|
submit();
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
@@ -124,7 +120,6 @@ describe('BlockModal', () => {
|
|||||||
it('requires at least one proclamation', async () => {
|
it('requires at least one proclamation', async () => {
|
||||||
mount({ initialProclamationIds: [] });
|
mount({ initialProclamationIds: [] });
|
||||||
|
|
||||||
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
|
|
||||||
submit();
|
submit();
|
||||||
|
|
||||||
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
|
await expect.element(page.getByText('Select at least one proclamation.')).toBeInTheDocument();
|
||||||
@@ -133,7 +128,6 @@ describe('BlockModal', () => {
|
|||||||
it('does not show an error for a valid selection', async () => {
|
it('does not show an error for a valid selection', async () => {
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
await page.getByRole('combobox', { name: /^Start hour$/ }).element();
|
|
||||||
const result = submit();
|
const result = submit();
|
||||||
|
|
||||||
expect(typeof result).toBe('function');
|
expect(typeof result).toBe('function');
|
||||||
@@ -165,21 +159,32 @@ describe('BlockModal', () => {
|
|||||||
|
|
||||||
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||||
await expect.element(checkbox).toBeInTheDocument();
|
await expect.element(checkbox).toBeInTheDocument();
|
||||||
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
|
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.element(page.getByText(/Dynamic audio will be enabled for this block/))
|
.element(page.getByText(/Dynamic audio will be enabled for this block/))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('defaults dynamic audio on for new blocks', async () => {
|
||||||
|
mount();
|
||||||
|
|
||||||
|
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||||
|
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
|
it('pre-checks dynamic audio when editing a block that has it enabled', async () => {
|
||||||
mount({ initialDynamicAudio: true });
|
mount({ initialDynamicAudio: true });
|
||||||
|
|
||||||
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||||
await expect.element(checkbox).toBeInTheDocument();
|
|
||||||
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
|
expect((checkbox.element() as HTMLInputElement).checked).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('leaves dynamic audio off when editing a block that has it disabled', async () => {
|
||||||
|
mount({ initialDynamicAudio: false });
|
||||||
|
|
||||||
|
const checkbox = page.getByRole('checkbox', { name: /Enable Dynamic Audio/ });
|
||||||
|
expect((checkbox.element() as HTMLInputElement).checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('toggles the dynamic audio flag in the submitted payload', async () => {
|
it('toggles the dynamic audio flag in the submitted payload', async () => {
|
||||||
mount();
|
mount();
|
||||||
|
|
||||||
@@ -188,6 +193,6 @@ describe('BlockModal', () => {
|
|||||||
(checkbox.element() as HTMLInputElement).click();
|
(checkbox.element() as HTMLInputElement).click();
|
||||||
|
|
||||||
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
|
const hidden = document.querySelector('input[name="dynamicAudio"]') as HTMLInputElement;
|
||||||
await expect.poll(() => hidden.value).toBe('true');
|
await expect.poll(() => hidden.value).toBe('false');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
<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,287 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
MINUTES_PER_DAY,
|
||||||
|
SNAP_MINUTES,
|
||||||
|
blockSegments,
|
||||||
|
formatTime,
|
||||||
|
isOvernight,
|
||||||
|
snapMinutes
|
||||||
|
} from '$lib/schedule-utils';
|
||||||
|
|
||||||
|
type ScheduleBlockView = {
|
||||||
|
id: string;
|
||||||
|
day?: string | null;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamations: { id: string; name: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type OpenBlock = {
|
||||||
|
id: string;
|
||||||
|
day: string | null;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamations: { id: string; name: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
days,
|
||||||
|
blocks = [],
|
||||||
|
onCreate,
|
||||||
|
onEdit
|
||||||
|
}: {
|
||||||
|
days: { key: string; label: string; short?: string }[];
|
||||||
|
blocks?: ScheduleBlockView[];
|
||||||
|
onCreate?: (day: string, startMinutes: number, endMinutes: number) => void;
|
||||||
|
onEdit?: (block: OpenBlock) => void;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const hourMarks = Array.from({ length: 24 }, (_, i) => i);
|
||||||
|
const defaultDuration = 60;
|
||||||
|
|
||||||
|
// Which grid the pointer is currently over, so the live preview shows up in
|
||||||
|
// the right day column (Google-Calendar style).
|
||||||
|
let drag = $state<{
|
||||||
|
day: string;
|
||||||
|
anchor: number;
|
||||||
|
current: number;
|
||||||
|
pointerId: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
function dayLabel(key: string): string {
|
||||||
|
return days.find((d) => d.key === key)?.label ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesFromEvent(event: PointerEvent, column: HTMLElement): number {
|
||||||
|
const rect = column.getBoundingClientRect();
|
||||||
|
// Guard against an unmeasured/zero-height column (e.g. before layout) so
|
||||||
|
// a tap still yields a valid, finite start minute instead of NaN.
|
||||||
|
if (!rect.height) return 0;
|
||||||
|
const ratio = (event.clientY - rect.top) / rect.height;
|
||||||
|
return Math.max(0, Math.min(MINUTES_PER_DAY, snapMinutes(ratio * MINUTES_PER_DAY)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pointer capture happens on the column element, so moves/ups land there even
|
||||||
|
// when the pointer leaves the small cell.
|
||||||
|
function startDrag(event: PointerEvent, day: string) {
|
||||||
|
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||||
|
const column = event.currentTarget as HTMLElement;
|
||||||
|
const minutes = minutesFromEvent(event, column);
|
||||||
|
drag = { day, anchor: minutes, current: minutes, pointerId: event.pointerId };
|
||||||
|
column.setPointerCapture(event.pointerId);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveDrag(event: PointerEvent) {
|
||||||
|
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||||
|
const column = event.currentTarget as HTMLElement;
|
||||||
|
drag = { ...drag, current: minutesFromEvent(event, column) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function endDrag(event: PointerEvent) {
|
||||||
|
if (!drag || event.pointerId !== drag.pointerId) return;
|
||||||
|
const { day, anchor, current } = drag;
|
||||||
|
const column = event.currentTarget as HTMLElement;
|
||||||
|
column.releasePointerCapture?.(event.pointerId);
|
||||||
|
drag = null;
|
||||||
|
|
||||||
|
const start = Math.min(anchor, current);
|
||||||
|
const end = Math.max(anchor, current);
|
||||||
|
if (end - start >= SNAP_MINUTES) {
|
||||||
|
onCreate?.(day, start, end);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plain click creates a default-length block starting at that slot.
|
||||||
|
const clickStart = Math.min(start, MINUTES_PER_DAY - defaultDuration);
|
||||||
|
const clickEnd = Math.min(MINUTES_PER_DAY, clickStart + defaultDuration);
|
||||||
|
onCreate?.(day, 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 {
|
||||||
|
day: drag.day,
|
||||||
|
top: (start / MINUTES_PER_DAY) * 100,
|
||||||
|
height: ((end - start) / MINUTES_PER_DAY) * 100
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function blocksForDay(day: string): ScheduleBlockView[] {
|
||||||
|
// Daily schedules use a single "daily" column while their Blocks carry
|
||||||
|
// day=null; weekly columns match the `mon`..`sun` keys directly.
|
||||||
|
return blocks.filter((b) => (day === 'daily' ? b.day == null : b.day === day));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The column before each day, wrapping across the schedule (mon's previous
|
||||||
|
// day is sun; the single daily column is its own previous day). Used to spill
|
||||||
|
// an overnight block's post-midnight tail into the next day's column.
|
||||||
|
const previousDayKey: Record<string, string> = $derived.by(() => {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
days.forEach((day, index) => {
|
||||||
|
map[day.key] = days[(index - 1 + days.length) % days.length].key;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
|
type DayView = {
|
||||||
|
block: ScheduleBlockView;
|
||||||
|
// A same-day block, the pre-midnight start of an overnight block, or the
|
||||||
|
// post-midnight tail of the previous day's overnight block.
|
||||||
|
part: 'whole' | 'head' | 'tail';
|
||||||
|
top: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toView(
|
||||||
|
block: ScheduleBlockView,
|
||||||
|
part: DayView['part'],
|
||||||
|
[start, end]: [number, number]
|
||||||
|
): DayView {
|
||||||
|
return {
|
||||||
|
block,
|
||||||
|
part,
|
||||||
|
top: (start / MINUTES_PER_DAY) * 100,
|
||||||
|
height: ((end - start) / MINUTES_PER_DAY) * 100
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A column shows its own blocks plus the tails of the previous day's
|
||||||
|
// overnight blocks. An overnight block contributes only its pre-midnight
|
||||||
|
// head to its start day; its tail is drawn at the top of the next day
|
||||||
|
// (weekly sun 7PM→1AM spills into mon). For daily, the previous day is the
|
||||||
|
// same single column, so both parts render together as before.
|
||||||
|
function viewsForDay(day: string): DayView[] {
|
||||||
|
const views: DayView[] = [];
|
||||||
|
|
||||||
|
for (const block of blocksForDay(day)) {
|
||||||
|
const head = blockSegments(block)[0];
|
||||||
|
if (!head || head[1] <= head[0]) continue;
|
||||||
|
views.push(toView(block, isOvernight(block) ? 'head' : 'whole', head));
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = previousDayKey[day];
|
||||||
|
if (previous) {
|
||||||
|
for (const block of blocksForDay(previous)) {
|
||||||
|
if (!isOvernight(block)) continue;
|
||||||
|
const tail = blockSegments(block)[1];
|
||||||
|
if (!tail || tail[1] <= tail[0]) continue;
|
||||||
|
views.push(toView(block, 'tail', tail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return views;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Schedule as a Google-Calendar-style grid: day columns side by side with a
|
||||||
|
shared time gutter. Scrolls horizontally on narrow screens (the gutter stays
|
||||||
|
pinned); tap/click or drag an empty slot to create a block at that time, tap a
|
||||||
|
block to edit it.
|
||||||
|
-->
|
||||||
|
<div
|
||||||
|
class="isolate overflow-hidden rounded-lg border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<div class="flex overflow-x-auto" style="overscroll-behavior-x: contain;">
|
||||||
|
<!-- Time gutter (pinned while the day columns scroll horizontally). -->
|
||||||
|
<div
|
||||||
|
class="sticky left-0 z-30 w-20 shrink-0 border-r border-slate-200 bg-white sm:w-24 dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<!-- Spacer aligning with the day header row. -->
|
||||||
|
<div class="h-12 border-b border-slate-200 dark:border-slate-800"></div>
|
||||||
|
<div class="relative h-[720px]" aria-hidden="true">
|
||||||
|
{#each hourMarks as hour (hour)}
|
||||||
|
<div
|
||||||
|
class="absolute right-2 -translate-y-1/2 text-xs whitespace-nowrap text-slate-400 tabular-nums dark:text-slate-500"
|
||||||
|
style={`top:${(hour / 24) * 100}%`}
|
||||||
|
>
|
||||||
|
{formatTime(hour * 60)}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day columns share the available width equally so a weekly view fits
|
||||||
|
without scrolling on desktop. Each keeps a minimum width, so when the
|
||||||
|
seven days cannot fit (phones) the row scrolls horizontally. -->
|
||||||
|
{#each days as day (day.key)}
|
||||||
|
<div
|
||||||
|
class="min-w-24 flex-1 basis-0 border-r border-slate-200 last:border-r-0 dark:border-slate-800"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-12 items-center justify-center border-b border-slate-200 px-1 text-xs font-semibold text-slate-700 sm:text-sm dark:border-slate-800 dark:text-slate-200"
|
||||||
|
>
|
||||||
|
{#if day.short}
|
||||||
|
<span class="sm:hidden">{day.short}</span>
|
||||||
|
<span class="hidden sm:inline">{day.label}</span>
|
||||||
|
{:else}
|
||||||
|
{day.label}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="relative h-[720px] cursor-pointer select-none"
|
||||||
|
style="touch-action: pan-x pan-y;"
|
||||||
|
aria-label={`Schedule grid ${dayLabel(day.key)}`}
|
||||||
|
onpointerdown={(e) => startDrag(e, day.key)}
|
||||||
|
onpointermove={moveDrag}
|
||||||
|
onpointerup={endDrag}
|
||||||
|
onpointercancel={(e) => {
|
||||||
|
if (drag && e.pointerId === drag.pointerId) 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 && preview.day === day.key}
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-1 z-10 rounded-md bg-indigo-200/70 dark:bg-indigo-700/50"
|
||||||
|
style={`top:${preview.top}%;height:${preview.height}%`}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each viewsForDay(day.key) as view (`${view.block.id}-${view.part}`)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onpointerdown={(event) => event.stopPropagation()}
|
||||||
|
onclick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onEdit?.({ ...view.block, day: view.block.day ?? null });
|
||||||
|
}}
|
||||||
|
aria-label={`Block ${formatTime(view.block.startMinutes)} to ${formatTime(view.block.endMinutes)}`}
|
||||||
|
class="absolute inset-x-0.5 z-20 overflow-hidden rounded-md border border-indigo-300 bg-indigo-50 px-1.5 py-1 text-left text-xs text-indigo-900 shadow-sm transition hover:bg-indigo-100 dark:border-indigo-800 dark:bg-indigo-950/70 dark:text-indigo-200 dark:hover:bg-indigo-900"
|
||||||
|
style={`top:${view.top}%;height:${view.height}%`}
|
||||||
|
>
|
||||||
|
{#if view.part === 'tail'}
|
||||||
|
<span class="block truncate font-semibold tabular-nums">
|
||||||
|
{formatTime(0)}–{formatTime(view.block.endMinutes)}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-[11px] opacity-80">↳ continues</span>
|
||||||
|
{:else}
|
||||||
|
<span class="block truncate font-semibold tabular-nums">
|
||||||
|
{formatTime(view.block.startMinutes)}–{formatTime(view.block.endMinutes)}
|
||||||
|
</span>
|
||||||
|
{#if view.part === 'head'}
|
||||||
|
<span class="block truncate text-[11px] opacity-80">ends next day</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
<span class="block truncate">
|
||||||
|
{view.block.proclamations.map((p) => p.name).join(', ') || 'No proclamations'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import WeekSchedule from './WeekSchedule.svelte';
|
||||||
|
|
||||||
|
const WEEK_DAYS = [
|
||||||
|
{ key: 'mon', label: 'Monday' },
|
||||||
|
{ key: 'tue', label: 'Tuesday' },
|
||||||
|
{ key: 'wed', label: 'Wednesday' },
|
||||||
|
{ key: 'thu', label: 'Thursday' },
|
||||||
|
{ key: 'fri', label: 'Friday' },
|
||||||
|
{ key: 'sat', label: 'Saturday' },
|
||||||
|
{ key: 'sun', label: 'Sunday' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const block = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
id: 'b1',
|
||||||
|
day: 'mon',
|
||||||
|
startMinutes: 6 * 60,
|
||||||
|
endMinutes: 8 * 60,
|
||||||
|
dynamicAudio: true,
|
||||||
|
proclamations: [{ id: 'p1', name: 'Peace' }],
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
|
||||||
|
function mount(props: Record<string, unknown> = {}) {
|
||||||
|
return render(WeekSchedule, { days: WEEK_DAYS, blocks: [], ...props });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click at a fraction of the way down a day column.
|
||||||
|
async function clickColumn(label: string, fraction: number) {
|
||||||
|
const column = document.querySelector(
|
||||||
|
`[aria-label="Schedule grid ${label}"]`
|
||||||
|
) as HTMLElement | null;
|
||||||
|
if (!column) throw new Error(`no column ${label}`);
|
||||||
|
const rect = column.getBoundingClientRect();
|
||||||
|
const x = rect.left + rect.width / 2;
|
||||||
|
const y = rect.top + rect.height * fraction;
|
||||||
|
column.dispatchEvent(
|
||||||
|
new PointerEvent('pointerdown', { clientX: x, clientY: y, bubbles: true, pointerId: 1 })
|
||||||
|
);
|
||||||
|
column.dispatchEvent(
|
||||||
|
new PointerEvent('pointerup', { clientX: x, clientY: y, bubbles: true, pointerId: 1 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WeekSchedule', () => {
|
||||||
|
it('renders all seven day columns side by side', async () => {
|
||||||
|
mount();
|
||||||
|
for (const day of WEEK_DAYS) {
|
||||||
|
await expect.element(page.getByText(day.label)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a single column for a daily schedule', async () => {
|
||||||
|
mount({ days: [{ key: 'daily', label: 'Every day' }] });
|
||||||
|
await expect.element(page.getByText('Every day')).toBeInTheDocument();
|
||||||
|
expect(document.querySelectorAll('[aria-label^="Schedule grid"]')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is horizontally scrollable with a pinned time gutter (mobile-friendly)', async () => {
|
||||||
|
mount();
|
||||||
|
|
||||||
|
// The day columns live in a horizontally scrollable row.
|
||||||
|
const scroller = document.querySelector('.overflow-x-auto') as HTMLElement | null;
|
||||||
|
expect(scroller).not.toBeNull();
|
||||||
|
|
||||||
|
// The time gutter is sticky so it stays visible while days scroll.
|
||||||
|
const gutter = scroller?.querySelector('[class*="sticky"][class*="left-0"]');
|
||||||
|
expect(gutter).not.toBeNull();
|
||||||
|
|
||||||
|
// Each day column has a minimum basis width so it stays legible on a phone.
|
||||||
|
const columns = scroller?.querySelectorAll('[aria-label^="Schedule grid"]') ?? [];
|
||||||
|
expect(columns.length).toBe(7);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows short day labels on small screens', async () => {
|
||||||
|
mount({
|
||||||
|
days: [
|
||||||
|
{ key: 'mon', label: 'Monday', short: 'Mon' },
|
||||||
|
{ key: 'tue', label: 'Tuesday', short: 'Tue' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
// Both the short (small-screen) and long (large-screen) labels exist in
|
||||||
|
// the DOM; CSS decides which is visible at a given width.
|
||||||
|
const text = document.body.textContent ?? '';
|
||||||
|
expect(text).toContain('Mon');
|
||||||
|
expect(text).toContain('Monday');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows blocks as events', async () => {
|
||||||
|
mount({ blocks: [block()] });
|
||||||
|
await expect.element(page.getByText('Peace')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders daily blocks (day null) in the single daily column', async () => {
|
||||||
|
mount({
|
||||||
|
days: [{ key: 'daily', label: 'Every day' }],
|
||||||
|
blocks: [block({ day: null })]
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Peace')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
function column(label: string): HTMLElement {
|
||||||
|
return document.querySelector(`[aria-label="Schedule grid ${label}"]`) as HTMLElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('spills an overnight weekly block into the next day column', async () => {
|
||||||
|
mount({ blocks: [block({ day: 'sun', startMinutes: 19 * 60, endMinutes: 60 })] });
|
||||||
|
|
||||||
|
// The start day keeps only the pre-midnight head.
|
||||||
|
const sunday = column('Sunday');
|
||||||
|
expect(sunday.textContent).toContain('7:00 PM–1:00 AM');
|
||||||
|
expect(sunday.textContent).toContain('ends next day');
|
||||||
|
expect(sunday.textContent).not.toContain('continues');
|
||||||
|
|
||||||
|
// The post-midnight tail is drawn on the following day.
|
||||||
|
const monday = column('Monday');
|
||||||
|
expect(monday.textContent).toContain('12:00 AM–1:00 AM');
|
||||||
|
expect(monday.textContent).toContain('↳ continues');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('spills a Saturday overnight block into Sunday', async () => {
|
||||||
|
mount({ blocks: [block({ day: 'sat', startMinutes: 23 * 60, endMinutes: 30 })] });
|
||||||
|
|
||||||
|
const sunday = column('Sunday');
|
||||||
|
expect(sunday.textContent).toContain('12:00 AM–12:30 AM');
|
||||||
|
expect(sunday.textContent).toContain('↳ continues');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps both parts of a daily overnight block in its single column', async () => {
|
||||||
|
mount({
|
||||||
|
days: [{ key: 'daily', label: 'Every day' }],
|
||||||
|
blocks: [block({ day: null, startMinutes: 21 * 60, endMinutes: 9 * 60 })]
|
||||||
|
});
|
||||||
|
|
||||||
|
const daily = column('Every day');
|
||||||
|
expect(daily.textContent).toContain('9:00 PM–9:00 AM');
|
||||||
|
expect(daily.textContent).toContain('ends next day');
|
||||||
|
expect(daily.textContent).toContain('12:00 AM–9:00 AM');
|
||||||
|
expect(daily.textContent).toContain('↳ continues');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens create on the clicked day', async () => {
|
||||||
|
const onCreate = vi.fn();
|
||||||
|
mount({ onCreate });
|
||||||
|
|
||||||
|
await clickColumn('Monday', 0.25);
|
||||||
|
|
||||||
|
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||||
|
const [day, start, end] = onCreate.mock.calls[0] as [string, number, number];
|
||||||
|
expect(day).toBe('mon');
|
||||||
|
// The handler always yields a sensible minute range (a default-length
|
||||||
|
// block when the click distance is below the snap threshold).
|
||||||
|
expect(Number.isFinite(start)).toBe(true);
|
||||||
|
expect(Number.isFinite(end)).toBe(true);
|
||||||
|
expect(end - start).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens edit when a block is clicked', async () => {
|
||||||
|
const onEdit = vi.fn();
|
||||||
|
mount({ blocks: [block()], onEdit });
|
||||||
|
|
||||||
|
await page.getByLabelText('Block 6:00 AM to 8:00 AM').click();
|
||||||
|
|
||||||
|
expect(onEdit).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onEdit.mock.calls[0][0]).toMatchObject({ id: 'b1', day: 'mon' });
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
@@ -2020,7 +2020,9 @@ export const LicenseKeyScalarFieldEnum = {
|
|||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
lastValidatedAt: 'lastValidatedAt',
|
lastValidatedAt: 'lastValidatedAt',
|
||||||
userId: 'userId',
|
userId: 'userId',
|
||||||
email: 'email'
|
email: 'email',
|
||||||
|
systemName: 'systemName',
|
||||||
|
systemNameLower: 'systemNameLower'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
|
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
|
||||||
@@ -2067,7 +2069,9 @@ export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typ
|
|||||||
|
|
||||||
export const ScheduleScalarFieldEnum = {
|
export const ScheduleScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
|
licenseKeyId: 'licenseKeyId',
|
||||||
userId: 'userId',
|
userId: 'userId',
|
||||||
|
type: 'type',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
} as const
|
} as const
|
||||||
@@ -2078,6 +2082,7 @@ export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typ
|
|||||||
export const BlockScalarFieldEnum = {
|
export const BlockScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
scheduleId: 'scheduleId',
|
scheduleId: 'scheduleId',
|
||||||
|
day: 'day',
|
||||||
startMinutes: 'startMinutes',
|
startMinutes: 'startMinutes',
|
||||||
endMinutes: 'endMinutes',
|
endMinutes: 'endMinutes',
|
||||||
dynamicAudio: 'dynamicAudio',
|
dynamicAudio: 'dynamicAudio',
|
||||||
|
|||||||
@@ -241,7 +241,9 @@ export const LicenseKeyScalarFieldEnum = {
|
|||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
lastValidatedAt: 'lastValidatedAt',
|
lastValidatedAt: 'lastValidatedAt',
|
||||||
userId: 'userId',
|
userId: 'userId',
|
||||||
email: 'email'
|
email: 'email',
|
||||||
|
systemName: 'systemName',
|
||||||
|
systemNameLower: 'systemNameLower'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
|
export type LicenseKeyScalarFieldEnum = (typeof LicenseKeyScalarFieldEnum)[keyof typeof LicenseKeyScalarFieldEnum]
|
||||||
@@ -288,7 +290,9 @@ export type AuditLogScalarFieldEnum = (typeof AuditLogScalarFieldEnum)[keyof typ
|
|||||||
|
|
||||||
export const ScheduleScalarFieldEnum = {
|
export const ScheduleScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
|
licenseKeyId: 'licenseKeyId',
|
||||||
userId: 'userId',
|
userId: 'userId',
|
||||||
|
type: 'type',
|
||||||
createdAt: 'createdAt',
|
createdAt: 'createdAt',
|
||||||
updatedAt: 'updatedAt'
|
updatedAt: 'updatedAt'
|
||||||
} as const
|
} as const
|
||||||
@@ -299,6 +303,7 @@ export type ScheduleScalarFieldEnum = (typeof ScheduleScalarFieldEnum)[keyof typ
|
|||||||
export const BlockScalarFieldEnum = {
|
export const BlockScalarFieldEnum = {
|
||||||
id: 'id',
|
id: 'id',
|
||||||
scheduleId: 'scheduleId',
|
scheduleId: 'scheduleId',
|
||||||
|
day: 'day',
|
||||||
startMinutes: 'startMinutes',
|
startMinutes: 'startMinutes',
|
||||||
endMinutes: 'endMinutes',
|
endMinutes: 'endMinutes',
|
||||||
dynamicAudio: 'dynamicAudio',
|
dynamicAudio: 'dynamicAudio',
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export type BlockSumAggregateOutputType = {
|
|||||||
export type BlockMinAggregateOutputType = {
|
export type BlockMinAggregateOutputType = {
|
||||||
id: string | null
|
id: string | null
|
||||||
scheduleId: string | null
|
scheduleId: string | null
|
||||||
|
day: string | null
|
||||||
startMinutes: number | null
|
startMinutes: number | null
|
||||||
endMinutes: number | null
|
endMinutes: number | null
|
||||||
dynamicAudio: boolean | null
|
dynamicAudio: boolean | null
|
||||||
@@ -49,6 +50,7 @@ export type BlockMinAggregateOutputType = {
|
|||||||
export type BlockMaxAggregateOutputType = {
|
export type BlockMaxAggregateOutputType = {
|
||||||
id: string | null
|
id: string | null
|
||||||
scheduleId: string | null
|
scheduleId: string | null
|
||||||
|
day: string | null
|
||||||
startMinutes: number | null
|
startMinutes: number | null
|
||||||
endMinutes: number | null
|
endMinutes: number | null
|
||||||
dynamicAudio: boolean | null
|
dynamicAudio: boolean | null
|
||||||
@@ -59,6 +61,7 @@ export type BlockMaxAggregateOutputType = {
|
|||||||
export type BlockCountAggregateOutputType = {
|
export type BlockCountAggregateOutputType = {
|
||||||
id: number
|
id: number
|
||||||
scheduleId: number
|
scheduleId: number
|
||||||
|
day: number
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio: number
|
dynamicAudio: number
|
||||||
@@ -81,6 +84,7 @@ export type BlockSumAggregateInputType = {
|
|||||||
export type BlockMinAggregateInputType = {
|
export type BlockMinAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
scheduleId?: true
|
scheduleId?: true
|
||||||
|
day?: true
|
||||||
startMinutes?: true
|
startMinutes?: true
|
||||||
endMinutes?: true
|
endMinutes?: true
|
||||||
dynamicAudio?: true
|
dynamicAudio?: true
|
||||||
@@ -91,6 +95,7 @@ export type BlockMinAggregateInputType = {
|
|||||||
export type BlockMaxAggregateInputType = {
|
export type BlockMaxAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
scheduleId?: true
|
scheduleId?: true
|
||||||
|
day?: true
|
||||||
startMinutes?: true
|
startMinutes?: true
|
||||||
endMinutes?: true
|
endMinutes?: true
|
||||||
dynamicAudio?: true
|
dynamicAudio?: true
|
||||||
@@ -101,6 +106,7 @@ export type BlockMaxAggregateInputType = {
|
|||||||
export type BlockCountAggregateInputType = {
|
export type BlockCountAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
scheduleId?: true
|
scheduleId?: true
|
||||||
|
day?: true
|
||||||
startMinutes?: true
|
startMinutes?: true
|
||||||
endMinutes?: true
|
endMinutes?: true
|
||||||
dynamicAudio?: true
|
dynamicAudio?: true
|
||||||
@@ -198,6 +204,7 @@ export type BlockGroupByArgs<ExtArgs extends runtime.Types.Extensions.InternalAr
|
|||||||
export type BlockGroupByOutputType = {
|
export type BlockGroupByOutputType = {
|
||||||
id: string
|
id: string
|
||||||
scheduleId: string
|
scheduleId: string
|
||||||
|
day: string | null
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio: boolean
|
dynamicAudio: boolean
|
||||||
@@ -231,6 +238,7 @@ export type BlockWhereInput = {
|
|||||||
NOT?: Prisma.BlockWhereInput | Prisma.BlockWhereInput[]
|
NOT?: Prisma.BlockWhereInput | Prisma.BlockWhereInput[]
|
||||||
id?: Prisma.StringFilter<"Block"> | string
|
id?: Prisma.StringFilter<"Block"> | string
|
||||||
scheduleId?: Prisma.StringFilter<"Block"> | string
|
scheduleId?: Prisma.StringFilter<"Block"> | string
|
||||||
|
day?: Prisma.StringNullableFilter<"Block"> | string | null
|
||||||
startMinutes?: Prisma.IntFilter<"Block"> | number
|
startMinutes?: Prisma.IntFilter<"Block"> | number
|
||||||
endMinutes?: Prisma.IntFilter<"Block"> | number
|
endMinutes?: Prisma.IntFilter<"Block"> | number
|
||||||
dynamicAudio?: Prisma.BoolFilter<"Block"> | boolean
|
dynamicAudio?: Prisma.BoolFilter<"Block"> | boolean
|
||||||
@@ -243,6 +251,7 @@ export type BlockWhereInput = {
|
|||||||
export type BlockOrderByWithRelationInput = {
|
export type BlockOrderByWithRelationInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
scheduleId?: Prisma.SortOrder
|
scheduleId?: Prisma.SortOrder
|
||||||
|
day?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
startMinutes?: Prisma.SortOrder
|
startMinutes?: Prisma.SortOrder
|
||||||
endMinutes?: Prisma.SortOrder
|
endMinutes?: Prisma.SortOrder
|
||||||
dynamicAudio?: Prisma.SortOrder
|
dynamicAudio?: Prisma.SortOrder
|
||||||
@@ -258,6 +267,7 @@ export type BlockWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
OR?: Prisma.BlockWhereInput[]
|
OR?: Prisma.BlockWhereInput[]
|
||||||
NOT?: Prisma.BlockWhereInput | Prisma.BlockWhereInput[]
|
NOT?: Prisma.BlockWhereInput | Prisma.BlockWhereInput[]
|
||||||
scheduleId?: Prisma.StringFilter<"Block"> | string
|
scheduleId?: Prisma.StringFilter<"Block"> | string
|
||||||
|
day?: Prisma.StringNullableFilter<"Block"> | string | null
|
||||||
startMinutes?: Prisma.IntFilter<"Block"> | number
|
startMinutes?: Prisma.IntFilter<"Block"> | number
|
||||||
endMinutes?: Prisma.IntFilter<"Block"> | number
|
endMinutes?: Prisma.IntFilter<"Block"> | number
|
||||||
dynamicAudio?: Prisma.BoolFilter<"Block"> | boolean
|
dynamicAudio?: Prisma.BoolFilter<"Block"> | boolean
|
||||||
@@ -270,6 +280,7 @@ export type BlockWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
export type BlockOrderByWithAggregationInput = {
|
export type BlockOrderByWithAggregationInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
scheduleId?: Prisma.SortOrder
|
scheduleId?: Prisma.SortOrder
|
||||||
|
day?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
startMinutes?: Prisma.SortOrder
|
startMinutes?: Prisma.SortOrder
|
||||||
endMinutes?: Prisma.SortOrder
|
endMinutes?: Prisma.SortOrder
|
||||||
dynamicAudio?: Prisma.SortOrder
|
dynamicAudio?: Prisma.SortOrder
|
||||||
@@ -288,6 +299,7 @@ export type BlockScalarWhereWithAggregatesInput = {
|
|||||||
NOT?: Prisma.BlockScalarWhereWithAggregatesInput | Prisma.BlockScalarWhereWithAggregatesInput[]
|
NOT?: Prisma.BlockScalarWhereWithAggregatesInput | Prisma.BlockScalarWhereWithAggregatesInput[]
|
||||||
id?: Prisma.StringWithAggregatesFilter<"Block"> | string
|
id?: Prisma.StringWithAggregatesFilter<"Block"> | string
|
||||||
scheduleId?: Prisma.StringWithAggregatesFilter<"Block"> | string
|
scheduleId?: Prisma.StringWithAggregatesFilter<"Block"> | string
|
||||||
|
day?: Prisma.StringNullableWithAggregatesFilter<"Block"> | string | null
|
||||||
startMinutes?: Prisma.IntWithAggregatesFilter<"Block"> | number
|
startMinutes?: Prisma.IntWithAggregatesFilter<"Block"> | number
|
||||||
endMinutes?: Prisma.IntWithAggregatesFilter<"Block"> | number
|
endMinutes?: Prisma.IntWithAggregatesFilter<"Block"> | number
|
||||||
dynamicAudio?: Prisma.BoolWithAggregatesFilter<"Block"> | boolean
|
dynamicAudio?: Prisma.BoolWithAggregatesFilter<"Block"> | boolean
|
||||||
@@ -297,6 +309,7 @@ export type BlockScalarWhereWithAggregatesInput = {
|
|||||||
|
|
||||||
export type BlockCreateInput = {
|
export type BlockCreateInput = {
|
||||||
id?: string
|
id?: string
|
||||||
|
day?: string | null
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -309,6 +322,7 @@ export type BlockCreateInput = {
|
|||||||
export type BlockUncheckedCreateInput = {
|
export type BlockUncheckedCreateInput = {
|
||||||
id?: string
|
id?: string
|
||||||
scheduleId: string
|
scheduleId: string
|
||||||
|
day?: string | null
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -319,6 +333,7 @@ export type BlockUncheckedCreateInput = {
|
|||||||
|
|
||||||
export type BlockUpdateInput = {
|
export type BlockUpdateInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
@@ -331,6 +346,7 @@ export type BlockUpdateInput = {
|
|||||||
export type BlockUncheckedUpdateInput = {
|
export type BlockUncheckedUpdateInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
scheduleId?: Prisma.StringFieldUpdateOperationsInput | string
|
scheduleId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
@@ -342,6 +358,7 @@ export type BlockUncheckedUpdateInput = {
|
|||||||
export type BlockCreateManyInput = {
|
export type BlockCreateManyInput = {
|
||||||
id?: string
|
id?: string
|
||||||
scheduleId: string
|
scheduleId: string
|
||||||
|
day?: string | null
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -351,6 +368,7 @@ export type BlockCreateManyInput = {
|
|||||||
|
|
||||||
export type BlockUpdateManyMutationInput = {
|
export type BlockUpdateManyMutationInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
@@ -361,6 +379,7 @@ export type BlockUpdateManyMutationInput = {
|
|||||||
export type BlockUncheckedUpdateManyInput = {
|
export type BlockUncheckedUpdateManyInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
scheduleId?: Prisma.StringFieldUpdateOperationsInput | string
|
scheduleId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
@@ -381,6 +400,7 @@ export type BlockOrderByRelationAggregateInput = {
|
|||||||
export type BlockCountOrderByAggregateInput = {
|
export type BlockCountOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
scheduleId?: Prisma.SortOrder
|
scheduleId?: Prisma.SortOrder
|
||||||
|
day?: Prisma.SortOrder
|
||||||
startMinutes?: Prisma.SortOrder
|
startMinutes?: Prisma.SortOrder
|
||||||
endMinutes?: Prisma.SortOrder
|
endMinutes?: Prisma.SortOrder
|
||||||
dynamicAudio?: Prisma.SortOrder
|
dynamicAudio?: Prisma.SortOrder
|
||||||
@@ -396,6 +416,7 @@ export type BlockAvgOrderByAggregateInput = {
|
|||||||
export type BlockMaxOrderByAggregateInput = {
|
export type BlockMaxOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
scheduleId?: Prisma.SortOrder
|
scheduleId?: Prisma.SortOrder
|
||||||
|
day?: Prisma.SortOrder
|
||||||
startMinutes?: Prisma.SortOrder
|
startMinutes?: Prisma.SortOrder
|
||||||
endMinutes?: Prisma.SortOrder
|
endMinutes?: Prisma.SortOrder
|
||||||
dynamicAudio?: Prisma.SortOrder
|
dynamicAudio?: Prisma.SortOrder
|
||||||
@@ -406,6 +427,7 @@ export type BlockMaxOrderByAggregateInput = {
|
|||||||
export type BlockMinOrderByAggregateInput = {
|
export type BlockMinOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
scheduleId?: Prisma.SortOrder
|
scheduleId?: Prisma.SortOrder
|
||||||
|
day?: Prisma.SortOrder
|
||||||
startMinutes?: Prisma.SortOrder
|
startMinutes?: Prisma.SortOrder
|
||||||
endMinutes?: Prisma.SortOrder
|
endMinutes?: Prisma.SortOrder
|
||||||
dynamicAudio?: Prisma.SortOrder
|
dynamicAudio?: Prisma.SortOrder
|
||||||
@@ -481,6 +503,7 @@ export type BlockUpdateOneRequiredWithoutProclamationsNestedInput = {
|
|||||||
|
|
||||||
export type BlockCreateWithoutScheduleInput = {
|
export type BlockCreateWithoutScheduleInput = {
|
||||||
id?: string
|
id?: string
|
||||||
|
day?: string | null
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -491,6 +514,7 @@ export type BlockCreateWithoutScheduleInput = {
|
|||||||
|
|
||||||
export type BlockUncheckedCreateWithoutScheduleInput = {
|
export type BlockUncheckedCreateWithoutScheduleInput = {
|
||||||
id?: string
|
id?: string
|
||||||
|
day?: string | null
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -531,6 +555,7 @@ export type BlockScalarWhereInput = {
|
|||||||
NOT?: Prisma.BlockScalarWhereInput | Prisma.BlockScalarWhereInput[]
|
NOT?: Prisma.BlockScalarWhereInput | Prisma.BlockScalarWhereInput[]
|
||||||
id?: Prisma.StringFilter<"Block"> | string
|
id?: Prisma.StringFilter<"Block"> | string
|
||||||
scheduleId?: Prisma.StringFilter<"Block"> | string
|
scheduleId?: Prisma.StringFilter<"Block"> | string
|
||||||
|
day?: Prisma.StringNullableFilter<"Block"> | string | null
|
||||||
startMinutes?: Prisma.IntFilter<"Block"> | number
|
startMinutes?: Prisma.IntFilter<"Block"> | number
|
||||||
endMinutes?: Prisma.IntFilter<"Block"> | number
|
endMinutes?: Prisma.IntFilter<"Block"> | number
|
||||||
dynamicAudio?: Prisma.BoolFilter<"Block"> | boolean
|
dynamicAudio?: Prisma.BoolFilter<"Block"> | boolean
|
||||||
@@ -540,6 +565,7 @@ export type BlockScalarWhereInput = {
|
|||||||
|
|
||||||
export type BlockCreateWithoutProclamationsInput = {
|
export type BlockCreateWithoutProclamationsInput = {
|
||||||
id?: string
|
id?: string
|
||||||
|
day?: string | null
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -551,6 +577,7 @@ export type BlockCreateWithoutProclamationsInput = {
|
|||||||
export type BlockUncheckedCreateWithoutProclamationsInput = {
|
export type BlockUncheckedCreateWithoutProclamationsInput = {
|
||||||
id?: string
|
id?: string
|
||||||
scheduleId: string
|
scheduleId: string
|
||||||
|
day?: string | null
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -576,6 +603,7 @@ export type BlockUpdateToOneWithWhereWithoutProclamationsInput = {
|
|||||||
|
|
||||||
export type BlockUpdateWithoutProclamationsInput = {
|
export type BlockUpdateWithoutProclamationsInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
@@ -587,6 +615,7 @@ export type BlockUpdateWithoutProclamationsInput = {
|
|||||||
export type BlockUncheckedUpdateWithoutProclamationsInput = {
|
export type BlockUncheckedUpdateWithoutProclamationsInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
scheduleId?: Prisma.StringFieldUpdateOperationsInput | string
|
scheduleId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
@@ -596,6 +625,7 @@ export type BlockUncheckedUpdateWithoutProclamationsInput = {
|
|||||||
|
|
||||||
export type BlockCreateManyScheduleInput = {
|
export type BlockCreateManyScheduleInput = {
|
||||||
id?: string
|
id?: string
|
||||||
|
day?: string | null
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -605,6 +635,7 @@ export type BlockCreateManyScheduleInput = {
|
|||||||
|
|
||||||
export type BlockUpdateWithoutScheduleInput = {
|
export type BlockUpdateWithoutScheduleInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
@@ -615,6 +646,7 @@ export type BlockUpdateWithoutScheduleInput = {
|
|||||||
|
|
||||||
export type BlockUncheckedUpdateWithoutScheduleInput = {
|
export type BlockUncheckedUpdateWithoutScheduleInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
@@ -625,6 +657,7 @@ export type BlockUncheckedUpdateWithoutScheduleInput = {
|
|||||||
|
|
||||||
export type BlockUncheckedUpdateManyWithoutScheduleInput = {
|
export type BlockUncheckedUpdateManyWithoutScheduleInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
day?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
startMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
endMinutes?: Prisma.IntFieldUpdateOperationsInput | number
|
||||||
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
dynamicAudio?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
@@ -666,6 +699,7 @@ export type BlockCountOutputTypeCountProclamationsArgs<ExtArgs extends runtime.T
|
|||||||
export type BlockSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type BlockSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
scheduleId?: boolean
|
scheduleId?: boolean
|
||||||
|
day?: boolean
|
||||||
startMinutes?: boolean
|
startMinutes?: boolean
|
||||||
endMinutes?: boolean
|
endMinutes?: boolean
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -679,6 +713,7 @@ export type BlockSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs =
|
|||||||
export type BlockSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type BlockSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
scheduleId?: boolean
|
scheduleId?: boolean
|
||||||
|
day?: boolean
|
||||||
startMinutes?: boolean
|
startMinutes?: boolean
|
||||||
endMinutes?: boolean
|
endMinutes?: boolean
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -690,6 +725,7 @@ export type BlockSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensi
|
|||||||
export type BlockSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type BlockSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
scheduleId?: boolean
|
scheduleId?: boolean
|
||||||
|
day?: boolean
|
||||||
startMinutes?: boolean
|
startMinutes?: boolean
|
||||||
endMinutes?: boolean
|
endMinutes?: boolean
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -701,6 +737,7 @@ export type BlockSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensi
|
|||||||
export type BlockSelectScalar = {
|
export type BlockSelectScalar = {
|
||||||
id?: boolean
|
id?: boolean
|
||||||
scheduleId?: boolean
|
scheduleId?: boolean
|
||||||
|
day?: boolean
|
||||||
startMinutes?: boolean
|
startMinutes?: boolean
|
||||||
endMinutes?: boolean
|
endMinutes?: boolean
|
||||||
dynamicAudio?: boolean
|
dynamicAudio?: boolean
|
||||||
@@ -708,7 +745,7 @@ export type BlockSelectScalar = {
|
|||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BlockOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "scheduleId" | "startMinutes" | "endMinutes" | "dynamicAudio" | "createdAt" | "updatedAt", ExtArgs["result"]["block"]>
|
export type BlockOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "scheduleId" | "day" | "startMinutes" | "endMinutes" | "dynamicAudio" | "createdAt" | "updatedAt", ExtArgs["result"]["block"]>
|
||||||
export type BlockInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type BlockInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
schedule?: boolean | Prisma.ScheduleDefaultArgs<ExtArgs>
|
schedule?: boolean | Prisma.ScheduleDefaultArgs<ExtArgs>
|
||||||
proclamations?: boolean | Prisma.Block$proclamationsArgs<ExtArgs>
|
proclamations?: boolean | Prisma.Block$proclamationsArgs<ExtArgs>
|
||||||
@@ -730,6 +767,7 @@ export type $BlockPayload<ExtArgs extends runtime.Types.Extensions.InternalArgs
|
|||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
id: string
|
||||||
scheduleId: string
|
scheduleId: string
|
||||||
|
day: string | null
|
||||||
startMinutes: number
|
startMinutes: number
|
||||||
endMinutes: number
|
endMinutes: number
|
||||||
dynamicAudio: boolean
|
dynamicAudio: boolean
|
||||||
@@ -1162,6 +1200,7 @@ export interface Prisma__BlockClient<T, Null = never, ExtArgs extends runtime.Ty
|
|||||||
export interface BlockFieldRefs {
|
export interface BlockFieldRefs {
|
||||||
readonly id: Prisma.FieldRef<"Block", 'String'>
|
readonly id: Prisma.FieldRef<"Block", 'String'>
|
||||||
readonly scheduleId: Prisma.FieldRef<"Block", 'String'>
|
readonly scheduleId: Prisma.FieldRef<"Block", 'String'>
|
||||||
|
readonly day: Prisma.FieldRef<"Block", 'String'>
|
||||||
readonly startMinutes: Prisma.FieldRef<"Block", 'Int'>
|
readonly startMinutes: Prisma.FieldRef<"Block", 'Int'>
|
||||||
readonly endMinutes: Prisma.FieldRef<"Block", 'Int'>
|
readonly endMinutes: Prisma.FieldRef<"Block", 'Int'>
|
||||||
readonly dynamicAudio: Prisma.FieldRef<"Block", 'Boolean'>
|
readonly dynamicAudio: Prisma.FieldRef<"Block", 'Boolean'>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export type LicenseKeyMinAggregateOutputType = {
|
|||||||
lastValidatedAt: Date | null
|
lastValidatedAt: Date | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
|
systemName: string | null
|
||||||
|
systemNameLower: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyMaxAggregateOutputType = {
|
export type LicenseKeyMaxAggregateOutputType = {
|
||||||
@@ -42,6 +44,8 @@ export type LicenseKeyMaxAggregateOutputType = {
|
|||||||
lastValidatedAt: Date | null
|
lastValidatedAt: Date | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
|
systemName: string | null
|
||||||
|
systemNameLower: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyCountAggregateOutputType = {
|
export type LicenseKeyCountAggregateOutputType = {
|
||||||
@@ -52,6 +56,8 @@ export type LicenseKeyCountAggregateOutputType = {
|
|||||||
lastValidatedAt: number
|
lastValidatedAt: number
|
||||||
userId: number
|
userId: number
|
||||||
email: number
|
email: number
|
||||||
|
systemName: number
|
||||||
|
systemNameLower: number
|
||||||
_all: number
|
_all: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +70,8 @@ export type LicenseKeyMinAggregateInputType = {
|
|||||||
lastValidatedAt?: true
|
lastValidatedAt?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
email?: true
|
email?: true
|
||||||
|
systemName?: true
|
||||||
|
systemNameLower?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyMaxAggregateInputType = {
|
export type LicenseKeyMaxAggregateInputType = {
|
||||||
@@ -74,6 +82,8 @@ export type LicenseKeyMaxAggregateInputType = {
|
|||||||
lastValidatedAt?: true
|
lastValidatedAt?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
email?: true
|
email?: true
|
||||||
|
systemName?: true
|
||||||
|
systemNameLower?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyCountAggregateInputType = {
|
export type LicenseKeyCountAggregateInputType = {
|
||||||
@@ -84,6 +94,8 @@ export type LicenseKeyCountAggregateInputType = {
|
|||||||
lastValidatedAt?: true
|
lastValidatedAt?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
email?: true
|
email?: true
|
||||||
|
systemName?: true
|
||||||
|
systemNameLower?: true
|
||||||
_all?: true
|
_all?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +179,8 @@ export type LicenseKeyGroupByOutputType = {
|
|||||||
lastValidatedAt: Date | null
|
lastValidatedAt: Date | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
|
systemName: string | null
|
||||||
|
systemNameLower: string | null
|
||||||
_count: LicenseKeyCountAggregateOutputType | null
|
_count: LicenseKeyCountAggregateOutputType | null
|
||||||
_min: LicenseKeyMinAggregateOutputType | null
|
_min: LicenseKeyMinAggregateOutputType | null
|
||||||
_max: LicenseKeyMaxAggregateOutputType | null
|
_max: LicenseKeyMaxAggregateOutputType | null
|
||||||
@@ -198,7 +212,10 @@ export type LicenseKeyWhereInput = {
|
|||||||
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
|
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
|
||||||
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
|
systemName?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
|
systemNameLower?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
|
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
|
||||||
|
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyOrderByWithRelationInput = {
|
export type LicenseKeyOrderByWithRelationInput = {
|
||||||
@@ -209,12 +226,16 @@ export type LicenseKeyOrderByWithRelationInput = {
|
|||||||
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrderInput | Prisma.SortOrder
|
userId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
email?: Prisma.SortOrderInput | Prisma.SortOrder
|
email?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
systemName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
systemNameLower?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
user?: Prisma.UserOrderByWithRelationInput
|
user?: Prisma.UserOrderByWithRelationInput
|
||||||
|
schedule?: Prisma.ScheduleOrderByWithRelationInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{
|
export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{
|
||||||
id?: string
|
id?: string
|
||||||
key?: string
|
key?: string
|
||||||
|
userId_systemNameLower?: Prisma.LicenseKeyUserIdSystemNameLowerCompoundUniqueInput
|
||||||
AND?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
|
AND?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
|
||||||
OR?: Prisma.LicenseKeyWhereInput[]
|
OR?: Prisma.LicenseKeyWhereInput[]
|
||||||
NOT?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
|
NOT?: Prisma.LicenseKeyWhereInput | Prisma.LicenseKeyWhereInput[]
|
||||||
@@ -223,8 +244,11 @@ export type LicenseKeyWhereUniqueInput = Prisma.AtLeast<{
|
|||||||
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
|
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
|
||||||
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
|
systemName?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
|
systemNameLower?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
|
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
|
||||||
}, "id" | "key">
|
schedule?: Prisma.XOR<Prisma.ScheduleNullableScalarRelationFilter, Prisma.ScheduleWhereInput> | null
|
||||||
|
}, "id" | "key" | "userId_systemNameLower">
|
||||||
|
|
||||||
export type LicenseKeyOrderByWithAggregationInput = {
|
export type LicenseKeyOrderByWithAggregationInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
@@ -234,6 +258,8 @@ export type LicenseKeyOrderByWithAggregationInput = {
|
|||||||
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
lastValidatedAt?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrderInput | Prisma.SortOrder
|
userId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
email?: Prisma.SortOrderInput | Prisma.SortOrder
|
email?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
systemName?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
systemNameLower?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
_count?: Prisma.LicenseKeyCountOrderByAggregateInput
|
_count?: Prisma.LicenseKeyCountOrderByAggregateInput
|
||||||
_max?: Prisma.LicenseKeyMaxOrderByAggregateInput
|
_max?: Prisma.LicenseKeyMaxOrderByAggregateInput
|
||||||
_min?: Prisma.LicenseKeyMinOrderByAggregateInput
|
_min?: Prisma.LicenseKeyMinOrderByAggregateInput
|
||||||
@@ -250,6 +276,8 @@ export type LicenseKeyScalarWhereWithAggregatesInput = {
|
|||||||
lastValidatedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"LicenseKey"> | Date | string | null
|
lastValidatedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"LicenseKey"> | Date | string | null
|
||||||
userId?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
|
userId?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
|
||||||
email?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
|
email?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
|
||||||
|
systemName?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
|
||||||
|
systemNameLower?: Prisma.StringNullableWithAggregatesFilter<"LicenseKey"> | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyCreateInput = {
|
export type LicenseKeyCreateInput = {
|
||||||
@@ -259,7 +287,10 @@ export type LicenseKeyCreateInput = {
|
|||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
lastValidatedAt?: Date | string | null
|
lastValidatedAt?: Date | string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
systemName?: string | null
|
||||||
|
systemNameLower?: string | null
|
||||||
user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
|
user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutLicenseKeyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyUncheckedCreateInput = {
|
export type LicenseKeyUncheckedCreateInput = {
|
||||||
@@ -270,6 +301,9 @@ export type LicenseKeyUncheckedCreateInput = {
|
|||||||
lastValidatedAt?: Date | string | null
|
lastValidatedAt?: Date | string | null
|
||||||
userId?: string | null
|
userId?: string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
systemName?: string | null
|
||||||
|
systemNameLower?: string | null
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutLicenseKeyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyUpdateInput = {
|
export type LicenseKeyUpdateInput = {
|
||||||
@@ -279,7 +313,10 @@ export type LicenseKeyUpdateInput = {
|
|||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
user?: Prisma.UserUpdateOneWithoutLicenseKeysNestedInput
|
user?: Prisma.UserUpdateOneWithoutLicenseKeysNestedInput
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutLicenseKeyNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyUncheckedUpdateInput = {
|
export type LicenseKeyUncheckedUpdateInput = {
|
||||||
@@ -290,6 +327,9 @@ export type LicenseKeyUncheckedUpdateInput = {
|
|||||||
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutLicenseKeyNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyCreateManyInput = {
|
export type LicenseKeyCreateManyInput = {
|
||||||
@@ -300,6 +340,8 @@ export type LicenseKeyCreateManyInput = {
|
|||||||
lastValidatedAt?: Date | string | null
|
lastValidatedAt?: Date | string | null
|
||||||
userId?: string | null
|
userId?: string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
systemName?: string | null
|
||||||
|
systemNameLower?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyUpdateManyMutationInput = {
|
export type LicenseKeyUpdateManyMutationInput = {
|
||||||
@@ -309,6 +351,8 @@ export type LicenseKeyUpdateManyMutationInput = {
|
|||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyUncheckedUpdateManyInput = {
|
export type LicenseKeyUncheckedUpdateManyInput = {
|
||||||
@@ -319,6 +363,8 @@ export type LicenseKeyUncheckedUpdateManyInput = {
|
|||||||
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyListRelationFilter = {
|
export type LicenseKeyListRelationFilter = {
|
||||||
@@ -331,6 +377,11 @@ export type LicenseKeyOrderByRelationAggregateInput = {
|
|||||||
_count?: Prisma.SortOrder
|
_count?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyUserIdSystemNameLowerCompoundUniqueInput = {
|
||||||
|
userId: string
|
||||||
|
systemNameLower: string
|
||||||
|
}
|
||||||
|
|
||||||
export type LicenseKeyCountOrderByAggregateInput = {
|
export type LicenseKeyCountOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
key?: Prisma.SortOrder
|
key?: Prisma.SortOrder
|
||||||
@@ -339,6 +390,8 @@ export type LicenseKeyCountOrderByAggregateInput = {
|
|||||||
lastValidatedAt?: Prisma.SortOrder
|
lastValidatedAt?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
|
systemName?: Prisma.SortOrder
|
||||||
|
systemNameLower?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyMaxOrderByAggregateInput = {
|
export type LicenseKeyMaxOrderByAggregateInput = {
|
||||||
@@ -349,6 +402,8 @@ export type LicenseKeyMaxOrderByAggregateInput = {
|
|||||||
lastValidatedAt?: Prisma.SortOrder
|
lastValidatedAt?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
|
systemName?: Prisma.SortOrder
|
||||||
|
systemNameLower?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyMinOrderByAggregateInput = {
|
export type LicenseKeyMinOrderByAggregateInput = {
|
||||||
@@ -359,6 +414,13 @@ export type LicenseKeyMinOrderByAggregateInput = {
|
|||||||
lastValidatedAt?: Prisma.SortOrder
|
lastValidatedAt?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
email?: Prisma.SortOrder
|
email?: Prisma.SortOrder
|
||||||
|
systemName?: Prisma.SortOrder
|
||||||
|
systemNameLower?: Prisma.SortOrder
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyNullableScalarRelationFilter = {
|
||||||
|
is?: Prisma.LicenseKeyWhereInput | null
|
||||||
|
isNot?: Prisma.LicenseKeyWhereInput | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyCreateNestedManyWithoutUserInput = {
|
export type LicenseKeyCreateNestedManyWithoutUserInput = {
|
||||||
@@ -403,6 +465,22 @@ export type LicenseKeyUncheckedUpdateManyWithoutUserNestedInput = {
|
|||||||
deleteMany?: Prisma.LicenseKeyScalarWhereInput | Prisma.LicenseKeyScalarWhereInput[]
|
deleteMany?: Prisma.LicenseKeyScalarWhereInput | Prisma.LicenseKeyScalarWhereInput[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyCreateNestedOneWithoutScheduleInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.LicenseKeyCreateWithoutScheduleInput, Prisma.LicenseKeyUncheckedCreateWithoutScheduleInput>
|
||||||
|
connectOrCreate?: Prisma.LicenseKeyCreateOrConnectWithoutScheduleInput
|
||||||
|
connect?: Prisma.LicenseKeyWhereUniqueInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyUpdateOneWithoutScheduleNestedInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.LicenseKeyCreateWithoutScheduleInput, Prisma.LicenseKeyUncheckedCreateWithoutScheduleInput>
|
||||||
|
connectOrCreate?: Prisma.LicenseKeyCreateOrConnectWithoutScheduleInput
|
||||||
|
upsert?: Prisma.LicenseKeyUpsertWithoutScheduleInput
|
||||||
|
disconnect?: Prisma.LicenseKeyWhereInput | boolean
|
||||||
|
delete?: Prisma.LicenseKeyWhereInput | boolean
|
||||||
|
connect?: Prisma.LicenseKeyWhereUniqueInput
|
||||||
|
update?: Prisma.XOR<Prisma.XOR<Prisma.LicenseKeyUpdateToOneWithWhereWithoutScheduleInput, Prisma.LicenseKeyUpdateWithoutScheduleInput>, Prisma.LicenseKeyUncheckedUpdateWithoutScheduleInput>
|
||||||
|
}
|
||||||
|
|
||||||
export type LicenseKeyCreateWithoutUserInput = {
|
export type LicenseKeyCreateWithoutUserInput = {
|
||||||
id?: string
|
id?: string
|
||||||
key: string
|
key: string
|
||||||
@@ -410,6 +488,9 @@ export type LicenseKeyCreateWithoutUserInput = {
|
|||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
lastValidatedAt?: Date | string | null
|
lastValidatedAt?: Date | string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
systemName?: string | null
|
||||||
|
systemNameLower?: string | null
|
||||||
|
schedule?: Prisma.ScheduleCreateNestedOneWithoutLicenseKeyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyUncheckedCreateWithoutUserInput = {
|
export type LicenseKeyUncheckedCreateWithoutUserInput = {
|
||||||
@@ -419,6 +500,9 @@ export type LicenseKeyUncheckedCreateWithoutUserInput = {
|
|||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
lastValidatedAt?: Date | string | null
|
lastValidatedAt?: Date | string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
systemName?: string | null
|
||||||
|
systemNameLower?: string | null
|
||||||
|
schedule?: Prisma.ScheduleUncheckedCreateNestedOneWithoutLicenseKeyInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyCreateOrConnectWithoutUserInput = {
|
export type LicenseKeyCreateOrConnectWithoutUserInput = {
|
||||||
@@ -458,6 +542,72 @@ export type LicenseKeyScalarWhereInput = {
|
|||||||
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
|
lastValidatedAt?: Prisma.DateTimeNullableFilter<"LicenseKey"> | Date | string | null
|
||||||
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
userId?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
email?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
|
systemName?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
|
systemNameLower?: Prisma.StringNullableFilter<"LicenseKey"> | string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyCreateWithoutScheduleInput = {
|
||||||
|
id?: string
|
||||||
|
key: string
|
||||||
|
active: boolean
|
||||||
|
createdAt?: Date | string
|
||||||
|
lastValidatedAt?: Date | string | null
|
||||||
|
email?: string | null
|
||||||
|
systemName?: string | null
|
||||||
|
systemNameLower?: string | null
|
||||||
|
user?: Prisma.UserCreateNestedOneWithoutLicenseKeysInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyUncheckedCreateWithoutScheduleInput = {
|
||||||
|
id?: string
|
||||||
|
key: string
|
||||||
|
active: boolean
|
||||||
|
createdAt?: Date | string
|
||||||
|
lastValidatedAt?: Date | string | null
|
||||||
|
userId?: string | null
|
||||||
|
email?: string | null
|
||||||
|
systemName?: string | null
|
||||||
|
systemNameLower?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyCreateOrConnectWithoutScheduleInput = {
|
||||||
|
where: Prisma.LicenseKeyWhereUniqueInput
|
||||||
|
create: Prisma.XOR<Prisma.LicenseKeyCreateWithoutScheduleInput, Prisma.LicenseKeyUncheckedCreateWithoutScheduleInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyUpsertWithoutScheduleInput = {
|
||||||
|
update: Prisma.XOR<Prisma.LicenseKeyUpdateWithoutScheduleInput, Prisma.LicenseKeyUncheckedUpdateWithoutScheduleInput>
|
||||||
|
create: Prisma.XOR<Prisma.LicenseKeyCreateWithoutScheduleInput, Prisma.LicenseKeyUncheckedCreateWithoutScheduleInput>
|
||||||
|
where?: Prisma.LicenseKeyWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyUpdateToOneWithWhereWithoutScheduleInput = {
|
||||||
|
where?: Prisma.LicenseKeyWhereInput
|
||||||
|
data: Prisma.XOR<Prisma.LicenseKeyUpdateWithoutScheduleInput, Prisma.LicenseKeyUncheckedUpdateWithoutScheduleInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyUpdateWithoutScheduleInput = {
|
||||||
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
key?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
user?: Prisma.UserUpdateOneWithoutLicenseKeysNestedInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LicenseKeyUncheckedUpdateWithoutScheduleInput = {
|
||||||
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
key?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
active?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
|
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyCreateManyUserInput = {
|
export type LicenseKeyCreateManyUserInput = {
|
||||||
@@ -467,6 +617,8 @@ export type LicenseKeyCreateManyUserInput = {
|
|||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
lastValidatedAt?: Date | string | null
|
lastValidatedAt?: Date | string | null
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
systemName?: string | null
|
||||||
|
systemNameLower?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyUpdateWithoutUserInput = {
|
export type LicenseKeyUpdateWithoutUserInput = {
|
||||||
@@ -476,6 +628,9 @@ export type LicenseKeyUpdateWithoutUserInput = {
|
|||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
schedule?: Prisma.ScheduleUpdateOneWithoutLicenseKeyNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyUncheckedUpdateWithoutUserInput = {
|
export type LicenseKeyUncheckedUpdateWithoutUserInput = {
|
||||||
@@ -485,6 +640,9 @@ export type LicenseKeyUncheckedUpdateWithoutUserInput = {
|
|||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutLicenseKeyNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyUncheckedUpdateManyWithoutUserInput = {
|
export type LicenseKeyUncheckedUpdateManyWithoutUserInput = {
|
||||||
@@ -494,6 +652,8 @@ export type LicenseKeyUncheckedUpdateManyWithoutUserInput = {
|
|||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
lastValidatedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
|
||||||
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
email?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
systemNameLower?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -506,7 +666,10 @@ export type LicenseKeySelect<ExtArgs extends runtime.Types.Extensions.InternalAr
|
|||||||
lastValidatedAt?: boolean
|
lastValidatedAt?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
email?: boolean
|
email?: boolean
|
||||||
|
systemName?: boolean
|
||||||
|
systemNameLower?: boolean
|
||||||
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
||||||
|
schedule?: boolean | Prisma.LicenseKey$scheduleArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["licenseKey"]>
|
}, ExtArgs["result"]["licenseKey"]>
|
||||||
|
|
||||||
export type LicenseKeySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type LicenseKeySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
@@ -517,6 +680,8 @@ export type LicenseKeySelectCreateManyAndReturn<ExtArgs extends runtime.Types.Ex
|
|||||||
lastValidatedAt?: boolean
|
lastValidatedAt?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
email?: boolean
|
email?: boolean
|
||||||
|
systemName?: boolean
|
||||||
|
systemNameLower?: boolean
|
||||||
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["licenseKey"]>
|
}, ExtArgs["result"]["licenseKey"]>
|
||||||
|
|
||||||
@@ -528,6 +693,8 @@ export type LicenseKeySelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Ex
|
|||||||
lastValidatedAt?: boolean
|
lastValidatedAt?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
email?: boolean
|
email?: boolean
|
||||||
|
systemName?: boolean
|
||||||
|
systemNameLower?: boolean
|
||||||
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["licenseKey"]>
|
}, ExtArgs["result"]["licenseKey"]>
|
||||||
|
|
||||||
@@ -539,11 +706,14 @@ export type LicenseKeySelectScalar = {
|
|||||||
lastValidatedAt?: boolean
|
lastValidatedAt?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
email?: boolean
|
email?: boolean
|
||||||
|
systemName?: boolean
|
||||||
|
systemNameLower?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LicenseKeyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "key" | "active" | "createdAt" | "lastValidatedAt" | "userId" | "email", ExtArgs["result"]["licenseKey"]>
|
export type LicenseKeyOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "key" | "active" | "createdAt" | "lastValidatedAt" | "userId" | "email" | "systemName" | "systemNameLower", ExtArgs["result"]["licenseKey"]>
|
||||||
export type LicenseKeyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type LicenseKeyInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
||||||
|
schedule?: boolean | Prisma.LicenseKey$scheduleArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
export type LicenseKeyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type LicenseKeyIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
user?: boolean | Prisma.LicenseKey$userArgs<ExtArgs>
|
||||||
@@ -556,6 +726,7 @@ export type $LicenseKeyPayload<ExtArgs extends runtime.Types.Extensions.Internal
|
|||||||
name: "LicenseKey"
|
name: "LicenseKey"
|
||||||
objects: {
|
objects: {
|
||||||
user: Prisma.$UserPayload<ExtArgs> | null
|
user: Prisma.$UserPayload<ExtArgs> | null
|
||||||
|
schedule: Prisma.$SchedulePayload<ExtArgs> | null
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
id: string
|
||||||
@@ -565,6 +736,8 @@ export type $LicenseKeyPayload<ExtArgs extends runtime.Types.Extensions.Internal
|
|||||||
lastValidatedAt: Date | null
|
lastValidatedAt: Date | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
email: string | null
|
email: string | null
|
||||||
|
systemName: string | null
|
||||||
|
systemNameLower: string | null
|
||||||
}, ExtArgs["result"]["licenseKey"]>
|
}, ExtArgs["result"]["licenseKey"]>
|
||||||
composites: {}
|
composites: {}
|
||||||
}
|
}
|
||||||
@@ -960,6 +1133,7 @@ readonly fields: LicenseKeyFieldRefs;
|
|||||||
export interface Prisma__LicenseKeyClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
|
export interface Prisma__LicenseKeyClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
|
||||||
readonly [Symbol.toStringTag]: "PrismaPromise"
|
readonly [Symbol.toStringTag]: "PrismaPromise"
|
||||||
user<T extends Prisma.LicenseKey$userArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.LicenseKey$userArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
|
user<T extends Prisma.LicenseKey$userArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.LicenseKey$userArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
|
||||||
|
schedule<T extends Prisma.LicenseKey$scheduleArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.LicenseKey$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.
|
||||||
@@ -996,6 +1170,8 @@ export interface LicenseKeyFieldRefs {
|
|||||||
readonly lastValidatedAt: Prisma.FieldRef<"LicenseKey", 'DateTime'>
|
readonly lastValidatedAt: Prisma.FieldRef<"LicenseKey", 'DateTime'>
|
||||||
readonly userId: Prisma.FieldRef<"LicenseKey", 'String'>
|
readonly userId: Prisma.FieldRef<"LicenseKey", 'String'>
|
||||||
readonly email: Prisma.FieldRef<"LicenseKey", 'String'>
|
readonly email: Prisma.FieldRef<"LicenseKey", 'String'>
|
||||||
|
readonly systemName: Prisma.FieldRef<"LicenseKey", 'String'>
|
||||||
|
readonly systemNameLower: Prisma.FieldRef<"LicenseKey", 'String'>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1415,6 +1591,25 @@ export type LicenseKey$userArgs<ExtArgs extends runtime.Types.Extensions.Interna
|
|||||||
where?: Prisma.UserWhereInput
|
where?: Prisma.UserWhereInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LicenseKey.schedule
|
||||||
|
*/
|
||||||
|
export type LicenseKey$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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* LicenseKey without action
|
* LicenseKey without action
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -26,21 +26,27 @@ export type AggregateSchedule = {
|
|||||||
|
|
||||||
export type ScheduleMinAggregateOutputType = {
|
export type ScheduleMinAggregateOutputType = {
|
||||||
id: string | null
|
id: string | null
|
||||||
|
licenseKeyId: string | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
|
type: string | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleMaxAggregateOutputType = {
|
export type ScheduleMaxAggregateOutputType = {
|
||||||
id: string | null
|
id: string | null
|
||||||
|
licenseKeyId: string | null
|
||||||
userId: string | null
|
userId: string | null
|
||||||
|
type: string | null
|
||||||
createdAt: Date | null
|
createdAt: Date | null
|
||||||
updatedAt: Date | null
|
updatedAt: Date | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleCountAggregateOutputType = {
|
export type ScheduleCountAggregateOutputType = {
|
||||||
id: number
|
id: number
|
||||||
|
licenseKeyId: number
|
||||||
userId: number
|
userId: number
|
||||||
|
type: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
_all: number
|
_all: number
|
||||||
@@ -49,21 +55,27 @@ export type ScheduleCountAggregateOutputType = {
|
|||||||
|
|
||||||
export type ScheduleMinAggregateInputType = {
|
export type ScheduleMinAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
|
licenseKeyId?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
|
type?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleMaxAggregateInputType = {
|
export type ScheduleMaxAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
|
licenseKeyId?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
|
type?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleCountAggregateInputType = {
|
export type ScheduleCountAggregateInputType = {
|
||||||
id?: true
|
id?: true
|
||||||
|
licenseKeyId?: true
|
||||||
userId?: true
|
userId?: true
|
||||||
|
type?: true
|
||||||
createdAt?: true
|
createdAt?: true
|
||||||
updatedAt?: true
|
updatedAt?: true
|
||||||
_all?: true
|
_all?: true
|
||||||
@@ -143,7 +155,9 @@ export type ScheduleGroupByArgs<ExtArgs extends runtime.Types.Extensions.Interna
|
|||||||
|
|
||||||
export type ScheduleGroupByOutputType = {
|
export type ScheduleGroupByOutputType = {
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
licenseKeyId: string | null
|
||||||
|
userId: string | null
|
||||||
|
type: string | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
_count: ScheduleCountAggregateOutputType | null
|
_count: ScheduleCountAggregateOutputType | null
|
||||||
@@ -171,37 +185,48 @@ export type ScheduleWhereInput = {
|
|||||||
OR?: Prisma.ScheduleWhereInput[]
|
OR?: Prisma.ScheduleWhereInput[]
|
||||||
NOT?: Prisma.ScheduleWhereInput | Prisma.ScheduleWhereInput[]
|
NOT?: Prisma.ScheduleWhereInput | Prisma.ScheduleWhereInput[]
|
||||||
id?: Prisma.StringFilter<"Schedule"> | string
|
id?: Prisma.StringFilter<"Schedule"> | string
|
||||||
userId?: Prisma.StringFilter<"Schedule"> | string
|
licenseKeyId?: Prisma.StringNullableFilter<"Schedule"> | string | null
|
||||||
|
userId?: Prisma.StringNullableFilter<"Schedule"> | string | null
|
||||||
|
type?: Prisma.StringNullableFilter<"Schedule"> | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
|
||||||
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
|
licenseKey?: Prisma.XOR<Prisma.LicenseKeyNullableScalarRelationFilter, Prisma.LicenseKeyWhereInput> | null
|
||||||
|
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
|
||||||
blocks?: Prisma.BlockListRelationFilter
|
blocks?: Prisma.BlockListRelationFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleOrderByWithRelationInput = {
|
export type ScheduleOrderByWithRelationInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
licenseKeyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
userId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
type?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
|
licenseKey?: Prisma.LicenseKeyOrderByWithRelationInput
|
||||||
user?: Prisma.UserOrderByWithRelationInput
|
user?: Prisma.UserOrderByWithRelationInput
|
||||||
blocks?: Prisma.BlockOrderByRelationAggregateInput
|
blocks?: Prisma.BlockOrderByRelationAggregateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleWhereUniqueInput = Prisma.AtLeast<{
|
export type ScheduleWhereUniqueInput = Prisma.AtLeast<{
|
||||||
id?: string
|
id?: string
|
||||||
userId?: string
|
licenseKeyId?: string
|
||||||
AND?: Prisma.ScheduleWhereInput | Prisma.ScheduleWhereInput[]
|
AND?: Prisma.ScheduleWhereInput | Prisma.ScheduleWhereInput[]
|
||||||
OR?: Prisma.ScheduleWhereInput[]
|
OR?: Prisma.ScheduleWhereInput[]
|
||||||
NOT?: Prisma.ScheduleWhereInput | Prisma.ScheduleWhereInput[]
|
NOT?: Prisma.ScheduleWhereInput | Prisma.ScheduleWhereInput[]
|
||||||
|
userId?: Prisma.StringNullableFilter<"Schedule"> | string | null
|
||||||
|
type?: Prisma.StringNullableFilter<"Schedule"> | string | null
|
||||||
createdAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
|
createdAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
|
updatedAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
|
||||||
user?: Prisma.XOR<Prisma.UserScalarRelationFilter, Prisma.UserWhereInput>
|
licenseKey?: Prisma.XOR<Prisma.LicenseKeyNullableScalarRelationFilter, Prisma.LicenseKeyWhereInput> | null
|
||||||
|
user?: Prisma.XOR<Prisma.UserNullableScalarRelationFilter, Prisma.UserWhereInput> | null
|
||||||
blocks?: Prisma.BlockListRelationFilter
|
blocks?: Prisma.BlockListRelationFilter
|
||||||
}, "id" | "userId">
|
}, "id" | "licenseKeyId">
|
||||||
|
|
||||||
export type ScheduleOrderByWithAggregationInput = {
|
export type ScheduleOrderByWithAggregationInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
licenseKeyId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
userId?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
|
type?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
_count?: Prisma.ScheduleCountOrderByAggregateInput
|
_count?: Prisma.ScheduleCountOrderByAggregateInput
|
||||||
@@ -214,22 +239,28 @@ export type ScheduleScalarWhereWithAggregatesInput = {
|
|||||||
OR?: Prisma.ScheduleScalarWhereWithAggregatesInput[]
|
OR?: Prisma.ScheduleScalarWhereWithAggregatesInput[]
|
||||||
NOT?: Prisma.ScheduleScalarWhereWithAggregatesInput | Prisma.ScheduleScalarWhereWithAggregatesInput[]
|
NOT?: Prisma.ScheduleScalarWhereWithAggregatesInput | Prisma.ScheduleScalarWhereWithAggregatesInput[]
|
||||||
id?: Prisma.StringWithAggregatesFilter<"Schedule"> | string
|
id?: Prisma.StringWithAggregatesFilter<"Schedule"> | string
|
||||||
userId?: Prisma.StringWithAggregatesFilter<"Schedule"> | string
|
licenseKeyId?: Prisma.StringNullableWithAggregatesFilter<"Schedule"> | string | null
|
||||||
|
userId?: Prisma.StringNullableWithAggregatesFilter<"Schedule"> | string | null
|
||||||
|
type?: Prisma.StringNullableWithAggregatesFilter<"Schedule"> | string | null
|
||||||
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Schedule"> | Date | string
|
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Schedule"> | Date | string
|
||||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Schedule"> | Date | string
|
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Schedule"> | Date | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleCreateInput = {
|
export type ScheduleCreateInput = {
|
||||||
id?: string
|
id?: string
|
||||||
|
type?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
user: Prisma.UserCreateNestedOneWithoutScheduleInput
|
licenseKey?: Prisma.LicenseKeyCreateNestedOneWithoutScheduleInput
|
||||||
|
user?: Prisma.UserCreateNestedOneWithoutSchedulesInput
|
||||||
blocks?: Prisma.BlockCreateNestedManyWithoutScheduleInput
|
blocks?: Prisma.BlockCreateNestedManyWithoutScheduleInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUncheckedCreateInput = {
|
export type ScheduleUncheckedCreateInput = {
|
||||||
id?: string
|
id?: string
|
||||||
userId: string
|
licenseKeyId?: string | null
|
||||||
|
userId?: string | null
|
||||||
|
type?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
blocks?: Prisma.BlockUncheckedCreateNestedManyWithoutScheduleInput
|
blocks?: Prisma.BlockUncheckedCreateNestedManyWithoutScheduleInput
|
||||||
@@ -237,15 +268,19 @@ export type ScheduleUncheckedCreateInput = {
|
|||||||
|
|
||||||
export type ScheduleUpdateInput = {
|
export type ScheduleUpdateInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
user?: Prisma.UserUpdateOneRequiredWithoutScheduleNestedInput
|
licenseKey?: Prisma.LicenseKeyUpdateOneWithoutScheduleNestedInput
|
||||||
|
user?: Prisma.UserUpdateOneWithoutSchedulesNestedInput
|
||||||
blocks?: Prisma.BlockUpdateManyWithoutScheduleNestedInput
|
blocks?: Prisma.BlockUpdateManyWithoutScheduleNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUncheckedUpdateInput = {
|
export type ScheduleUncheckedUpdateInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
licenseKeyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
blocks?: Prisma.BlockUncheckedUpdateManyWithoutScheduleNestedInput
|
blocks?: Prisma.BlockUncheckedUpdateManyWithoutScheduleNestedInput
|
||||||
@@ -253,24 +288,39 @@ export type ScheduleUncheckedUpdateInput = {
|
|||||||
|
|
||||||
export type ScheduleCreateManyInput = {
|
export type ScheduleCreateManyInput = {
|
||||||
id?: string
|
id?: string
|
||||||
userId: string
|
licenseKeyId?: string | null
|
||||||
|
userId?: string | null
|
||||||
|
type?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUpdateManyMutationInput = {
|
export type ScheduleUpdateManyMutationInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUncheckedUpdateManyInput = {
|
export type ScheduleUncheckedUpdateManyInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
licenseKeyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ScheduleListRelationFilter = {
|
||||||
|
every?: Prisma.ScheduleWhereInput
|
||||||
|
some?: Prisma.ScheduleWhereInput
|
||||||
|
none?: Prisma.ScheduleWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleOrderByRelationAggregateInput = {
|
||||||
|
_count?: Prisma.SortOrder
|
||||||
|
}
|
||||||
|
|
||||||
export type ScheduleNullableScalarRelationFilter = {
|
export type ScheduleNullableScalarRelationFilter = {
|
||||||
is?: Prisma.ScheduleWhereInput | null
|
is?: Prisma.ScheduleWhereInput | null
|
||||||
isNot?: Prisma.ScheduleWhereInput | null
|
isNot?: Prisma.ScheduleWhereInput | null
|
||||||
@@ -278,21 +328,27 @@ export type ScheduleNullableScalarRelationFilter = {
|
|||||||
|
|
||||||
export type ScheduleCountOrderByAggregateInput = {
|
export type ScheduleCountOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
|
licenseKeyId?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
|
type?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleMaxOrderByAggregateInput = {
|
export type ScheduleMaxOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
|
licenseKeyId?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
|
type?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleMinOrderByAggregateInput = {
|
export type ScheduleMinOrderByAggregateInput = {
|
||||||
id?: Prisma.SortOrder
|
id?: Prisma.SortOrder
|
||||||
|
licenseKeyId?: Prisma.SortOrder
|
||||||
userId?: Prisma.SortOrder
|
userId?: Prisma.SortOrder
|
||||||
|
type?: Prisma.SortOrder
|
||||||
createdAt?: Prisma.SortOrder
|
createdAt?: Prisma.SortOrder
|
||||||
updatedAt?: Prisma.SortOrder
|
updatedAt?: Prisma.SortOrder
|
||||||
}
|
}
|
||||||
@@ -302,36 +358,78 @@ export type ScheduleScalarRelationFilter = {
|
|||||||
isNot?: Prisma.ScheduleWhereInput
|
isNot?: Prisma.ScheduleWhereInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleCreateNestedOneWithoutUserInput = {
|
export type ScheduleCreateNestedManyWithoutUserInput = {
|
||||||
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
|
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput> | Prisma.ScheduleCreateWithoutUserInput[] | Prisma.ScheduleUncheckedCreateWithoutUserInput[]
|
||||||
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput
|
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput | Prisma.ScheduleCreateOrConnectWithoutUserInput[]
|
||||||
|
createMany?: Prisma.ScheduleCreateManyUserInputEnvelope
|
||||||
|
connect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUncheckedCreateNestedManyWithoutUserInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput> | Prisma.ScheduleCreateWithoutUserInput[] | Prisma.ScheduleUncheckedCreateWithoutUserInput[]
|
||||||
|
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput | Prisma.ScheduleCreateOrConnectWithoutUserInput[]
|
||||||
|
createMany?: Prisma.ScheduleCreateManyUserInputEnvelope
|
||||||
|
connect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUpdateManyWithoutUserNestedInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput> | Prisma.ScheduleCreateWithoutUserInput[] | Prisma.ScheduleUncheckedCreateWithoutUserInput[]
|
||||||
|
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput | Prisma.ScheduleCreateOrConnectWithoutUserInput[]
|
||||||
|
upsert?: Prisma.ScheduleUpsertWithWhereUniqueWithoutUserInput | Prisma.ScheduleUpsertWithWhereUniqueWithoutUserInput[]
|
||||||
|
createMany?: Prisma.ScheduleCreateManyUserInputEnvelope
|
||||||
|
set?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
|
||||||
|
disconnect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
|
||||||
|
delete?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
|
||||||
|
connect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
|
||||||
|
update?: Prisma.ScheduleUpdateWithWhereUniqueWithoutUserInput | Prisma.ScheduleUpdateWithWhereUniqueWithoutUserInput[]
|
||||||
|
updateMany?: Prisma.ScheduleUpdateManyWithWhereWithoutUserInput | Prisma.ScheduleUpdateManyWithWhereWithoutUserInput[]
|
||||||
|
deleteMany?: Prisma.ScheduleScalarWhereInput | Prisma.ScheduleScalarWhereInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUncheckedUpdateManyWithoutUserNestedInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput> | Prisma.ScheduleCreateWithoutUserInput[] | Prisma.ScheduleUncheckedCreateWithoutUserInput[]
|
||||||
|
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput | Prisma.ScheduleCreateOrConnectWithoutUserInput[]
|
||||||
|
upsert?: Prisma.ScheduleUpsertWithWhereUniqueWithoutUserInput | Prisma.ScheduleUpsertWithWhereUniqueWithoutUserInput[]
|
||||||
|
createMany?: Prisma.ScheduleCreateManyUserInputEnvelope
|
||||||
|
set?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
|
||||||
|
disconnect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
|
||||||
|
delete?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
|
||||||
|
connect?: Prisma.ScheduleWhereUniqueInput | Prisma.ScheduleWhereUniqueInput[]
|
||||||
|
update?: Prisma.ScheduleUpdateWithWhereUniqueWithoutUserInput | Prisma.ScheduleUpdateWithWhereUniqueWithoutUserInput[]
|
||||||
|
updateMany?: Prisma.ScheduleUpdateManyWithWhereWithoutUserInput | Prisma.ScheduleUpdateManyWithWhereWithoutUserInput[]
|
||||||
|
deleteMany?: Prisma.ScheduleScalarWhereInput | Prisma.ScheduleScalarWhereInput[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleCreateNestedOneWithoutLicenseKeyInput = {
|
||||||
|
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
|
||||||
|
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput
|
||||||
connect?: Prisma.ScheduleWhereUniqueInput
|
connect?: Prisma.ScheduleWhereUniqueInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUncheckedCreateNestedOneWithoutUserInput = {
|
export type ScheduleUncheckedCreateNestedOneWithoutLicenseKeyInput = {
|
||||||
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
|
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
|
||||||
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput
|
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput
|
||||||
connect?: Prisma.ScheduleWhereUniqueInput
|
connect?: Prisma.ScheduleWhereUniqueInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUpdateOneWithoutUserNestedInput = {
|
export type ScheduleUpdateOneWithoutLicenseKeyNestedInput = {
|
||||||
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
|
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
|
||||||
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput
|
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput
|
||||||
upsert?: Prisma.ScheduleUpsertWithoutUserInput
|
upsert?: Prisma.ScheduleUpsertWithoutLicenseKeyInput
|
||||||
disconnect?: Prisma.ScheduleWhereInput | boolean
|
disconnect?: Prisma.ScheduleWhereInput | boolean
|
||||||
delete?: Prisma.ScheduleWhereInput | boolean
|
delete?: Prisma.ScheduleWhereInput | boolean
|
||||||
connect?: Prisma.ScheduleWhereUniqueInput
|
connect?: Prisma.ScheduleWhereUniqueInput
|
||||||
update?: Prisma.XOR<Prisma.XOR<Prisma.ScheduleUpdateToOneWithWhereWithoutUserInput, Prisma.ScheduleUpdateWithoutUserInput>, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
|
update?: Prisma.XOR<Prisma.XOR<Prisma.ScheduleUpdateToOneWithWhereWithoutLicenseKeyInput, Prisma.ScheduleUpdateWithoutLicenseKeyInput>, Prisma.ScheduleUncheckedUpdateWithoutLicenseKeyInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUncheckedUpdateOneWithoutUserNestedInput = {
|
export type ScheduleUncheckedUpdateOneWithoutLicenseKeyNestedInput = {
|
||||||
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
|
create?: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
|
||||||
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutUserInput
|
connectOrCreate?: Prisma.ScheduleCreateOrConnectWithoutLicenseKeyInput
|
||||||
upsert?: Prisma.ScheduleUpsertWithoutUserInput
|
upsert?: Prisma.ScheduleUpsertWithoutLicenseKeyInput
|
||||||
disconnect?: Prisma.ScheduleWhereInput | boolean
|
disconnect?: Prisma.ScheduleWhereInput | boolean
|
||||||
delete?: Prisma.ScheduleWhereInput | boolean
|
delete?: Prisma.ScheduleWhereInput | boolean
|
||||||
connect?: Prisma.ScheduleWhereUniqueInput
|
connect?: Prisma.ScheduleWhereUniqueInput
|
||||||
update?: Prisma.XOR<Prisma.XOR<Prisma.ScheduleUpdateToOneWithWhereWithoutUserInput, Prisma.ScheduleUpdateWithoutUserInput>, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
|
update?: Prisma.XOR<Prisma.XOR<Prisma.ScheduleUpdateToOneWithWhereWithoutLicenseKeyInput, Prisma.ScheduleUpdateWithoutLicenseKeyInput>, Prisma.ScheduleUncheckedUpdateWithoutLicenseKeyInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleCreateNestedOneWithoutBlocksInput = {
|
export type ScheduleCreateNestedOneWithoutBlocksInput = {
|
||||||
@@ -350,13 +448,17 @@ export type ScheduleUpdateOneRequiredWithoutBlocksNestedInput = {
|
|||||||
|
|
||||||
export type ScheduleCreateWithoutUserInput = {
|
export type ScheduleCreateWithoutUserInput = {
|
||||||
id?: string
|
id?: string
|
||||||
|
type?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
|
licenseKey?: Prisma.LicenseKeyCreateNestedOneWithoutScheduleInput
|
||||||
blocks?: Prisma.BlockCreateNestedManyWithoutScheduleInput
|
blocks?: Prisma.BlockCreateNestedManyWithoutScheduleInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUncheckedCreateWithoutUserInput = {
|
export type ScheduleUncheckedCreateWithoutUserInput = {
|
||||||
id?: string
|
id?: string
|
||||||
|
licenseKeyId?: string | null
|
||||||
|
type?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
blocks?: Prisma.BlockUncheckedCreateNestedManyWithoutScheduleInput
|
blocks?: Prisma.BlockUncheckedCreateNestedManyWithoutScheduleInput
|
||||||
@@ -367,26 +469,86 @@ export type ScheduleCreateOrConnectWithoutUserInput = {
|
|||||||
create: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
|
create: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUpsertWithoutUserInput = {
|
export type ScheduleCreateManyUserInputEnvelope = {
|
||||||
update: Prisma.XOR<Prisma.ScheduleUpdateWithoutUserInput, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
|
data: Prisma.ScheduleCreateManyUserInput | Prisma.ScheduleCreateManyUserInput[]
|
||||||
create: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
|
skipDuplicates?: boolean
|
||||||
where?: Prisma.ScheduleWhereInput
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUpdateToOneWithWhereWithoutUserInput = {
|
export type ScheduleUpsertWithWhereUniqueWithoutUserInput = {
|
||||||
where?: Prisma.ScheduleWhereInput
|
where: Prisma.ScheduleWhereUniqueInput
|
||||||
|
update: Prisma.XOR<Prisma.ScheduleUpdateWithoutUserInput, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
|
||||||
|
create: Prisma.XOR<Prisma.ScheduleCreateWithoutUserInput, Prisma.ScheduleUncheckedCreateWithoutUserInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUpdateWithWhereUniqueWithoutUserInput = {
|
||||||
|
where: Prisma.ScheduleWhereUniqueInput
|
||||||
data: Prisma.XOR<Prisma.ScheduleUpdateWithoutUserInput, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
|
data: Prisma.XOR<Prisma.ScheduleUpdateWithoutUserInput, Prisma.ScheduleUncheckedUpdateWithoutUserInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUpdateWithoutUserInput = {
|
export type ScheduleUpdateManyWithWhereWithoutUserInput = {
|
||||||
|
where: Prisma.ScheduleScalarWhereInput
|
||||||
|
data: Prisma.XOR<Prisma.ScheduleUpdateManyMutationInput, Prisma.ScheduleUncheckedUpdateManyWithoutUserInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleScalarWhereInput = {
|
||||||
|
AND?: Prisma.ScheduleScalarWhereInput | Prisma.ScheduleScalarWhereInput[]
|
||||||
|
OR?: Prisma.ScheduleScalarWhereInput[]
|
||||||
|
NOT?: Prisma.ScheduleScalarWhereInput | Prisma.ScheduleScalarWhereInput[]
|
||||||
|
id?: Prisma.StringFilter<"Schedule"> | string
|
||||||
|
licenseKeyId?: Prisma.StringNullableFilter<"Schedule"> | string | null
|
||||||
|
userId?: Prisma.StringNullableFilter<"Schedule"> | string | null
|
||||||
|
type?: Prisma.StringNullableFilter<"Schedule"> | string | null
|
||||||
|
createdAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
|
||||||
|
updatedAt?: Prisma.DateTimeFilter<"Schedule"> | Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleCreateWithoutLicenseKeyInput = {
|
||||||
|
id?: string
|
||||||
|
type?: string | null
|
||||||
|
createdAt?: Date | string
|
||||||
|
updatedAt?: Date | string
|
||||||
|
user?: Prisma.UserCreateNestedOneWithoutSchedulesInput
|
||||||
|
blocks?: Prisma.BlockCreateNestedManyWithoutScheduleInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUncheckedCreateWithoutLicenseKeyInput = {
|
||||||
|
id?: string
|
||||||
|
userId?: string | null
|
||||||
|
type?: string | null
|
||||||
|
createdAt?: Date | string
|
||||||
|
updatedAt?: Date | string
|
||||||
|
blocks?: Prisma.BlockUncheckedCreateNestedManyWithoutScheduleInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleCreateOrConnectWithoutLicenseKeyInput = {
|
||||||
|
where: Prisma.ScheduleWhereUniqueInput
|
||||||
|
create: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUpsertWithoutLicenseKeyInput = {
|
||||||
|
update: Prisma.XOR<Prisma.ScheduleUpdateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedUpdateWithoutLicenseKeyInput>
|
||||||
|
create: Prisma.XOR<Prisma.ScheduleCreateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedCreateWithoutLicenseKeyInput>
|
||||||
|
where?: Prisma.ScheduleWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUpdateToOneWithWhereWithoutLicenseKeyInput = {
|
||||||
|
where?: Prisma.ScheduleWhereInput
|
||||||
|
data: Prisma.XOR<Prisma.ScheduleUpdateWithoutLicenseKeyInput, Prisma.ScheduleUncheckedUpdateWithoutLicenseKeyInput>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUpdateWithoutLicenseKeyInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
user?: Prisma.UserUpdateOneWithoutSchedulesNestedInput
|
||||||
blocks?: Prisma.BlockUpdateManyWithoutScheduleNestedInput
|
blocks?: Prisma.BlockUpdateManyWithoutScheduleNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUncheckedUpdateWithoutUserInput = {
|
export type ScheduleUncheckedUpdateWithoutLicenseKeyInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
blocks?: Prisma.BlockUncheckedUpdateManyWithoutScheduleNestedInput
|
blocks?: Prisma.BlockUncheckedUpdateManyWithoutScheduleNestedInput
|
||||||
@@ -394,14 +556,18 @@ export type ScheduleUncheckedUpdateWithoutUserInput = {
|
|||||||
|
|
||||||
export type ScheduleCreateWithoutBlocksInput = {
|
export type ScheduleCreateWithoutBlocksInput = {
|
||||||
id?: string
|
id?: string
|
||||||
|
type?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
user: Prisma.UserCreateNestedOneWithoutScheduleInput
|
licenseKey?: Prisma.LicenseKeyCreateNestedOneWithoutScheduleInput
|
||||||
|
user?: Prisma.UserCreateNestedOneWithoutSchedulesInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUncheckedCreateWithoutBlocksInput = {
|
export type ScheduleUncheckedCreateWithoutBlocksInput = {
|
||||||
id?: string
|
id?: string
|
||||||
userId: string
|
licenseKeyId?: string | null
|
||||||
|
userId?: string | null
|
||||||
|
type?: string | null
|
||||||
createdAt?: Date | string
|
createdAt?: Date | string
|
||||||
updatedAt?: Date | string
|
updatedAt?: Date | string
|
||||||
}
|
}
|
||||||
@@ -424,14 +590,52 @@ export type ScheduleUpdateToOneWithWhereWithoutBlocksInput = {
|
|||||||
|
|
||||||
export type ScheduleUpdateWithoutBlocksInput = {
|
export type ScheduleUpdateWithoutBlocksInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
user?: Prisma.UserUpdateOneRequiredWithoutScheduleNestedInput
|
licenseKey?: Prisma.LicenseKeyUpdateOneWithoutScheduleNestedInput
|
||||||
|
user?: Prisma.UserUpdateOneWithoutSchedulesNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleUncheckedUpdateWithoutBlocksInput = {
|
export type ScheduleUncheckedUpdateWithoutBlocksInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
userId?: Prisma.StringFieldUpdateOperationsInput | string
|
licenseKeyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
userId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleCreateManyUserInput = {
|
||||||
|
id?: string
|
||||||
|
licenseKeyId?: string | null
|
||||||
|
type?: string | null
|
||||||
|
createdAt?: Date | string
|
||||||
|
updatedAt?: Date | string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUpdateWithoutUserInput = {
|
||||||
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
licenseKey?: Prisma.LicenseKeyUpdateOneWithoutScheduleNestedInput
|
||||||
|
blocks?: Prisma.BlockUpdateManyWithoutScheduleNestedInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUncheckedUpdateWithoutUserInput = {
|
||||||
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
licenseKeyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
|
blocks?: Prisma.BlockUncheckedUpdateManyWithoutScheduleNestedInput
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScheduleUncheckedUpdateManyWithoutUserInput = {
|
||||||
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
|
licenseKeyId?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
|
type?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||||
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||||
}
|
}
|
||||||
@@ -469,59 +673,76 @@ export type ScheduleCountOutputTypeCountBlocksArgs<ExtArgs extends runtime.Types
|
|||||||
|
|
||||||
export type ScheduleSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type ScheduleSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
|
licenseKeyId?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
|
type?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
|
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
|
||||||
|
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
|
||||||
blocks?: boolean | Prisma.Schedule$blocksArgs<ExtArgs>
|
blocks?: boolean | Prisma.Schedule$blocksArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.ScheduleCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.ScheduleCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["schedule"]>
|
}, ExtArgs["result"]["schedule"]>
|
||||||
|
|
||||||
export type ScheduleSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type ScheduleSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
|
licenseKeyId?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
|
type?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
|
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
|
||||||
|
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["schedule"]>
|
}, ExtArgs["result"]["schedule"]>
|
||||||
|
|
||||||
export type ScheduleSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type ScheduleSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
|
licenseKeyId?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
|
type?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
|
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
|
||||||
|
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["schedule"]>
|
}, ExtArgs["result"]["schedule"]>
|
||||||
|
|
||||||
export type ScheduleSelectScalar = {
|
export type ScheduleSelectScalar = {
|
||||||
id?: boolean
|
id?: boolean
|
||||||
|
licenseKeyId?: boolean
|
||||||
userId?: boolean
|
userId?: boolean
|
||||||
|
type?: boolean
|
||||||
createdAt?: boolean
|
createdAt?: boolean
|
||||||
updatedAt?: boolean
|
updatedAt?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ScheduleOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "userId" | "createdAt" | "updatedAt", ExtArgs["result"]["schedule"]>
|
export type ScheduleOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "licenseKeyId" | "userId" | "type" | "createdAt" | "updatedAt", ExtArgs["result"]["schedule"]>
|
||||||
export type ScheduleInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type ScheduleInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
|
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
|
||||||
|
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
|
||||||
blocks?: boolean | Prisma.Schedule$blocksArgs<ExtArgs>
|
blocks?: boolean | Prisma.Schedule$blocksArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.ScheduleCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.ScheduleCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
export type ScheduleIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type ScheduleIncludeCreateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
|
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
|
||||||
|
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
export type ScheduleIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type ScheduleIncludeUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
user?: boolean | Prisma.UserDefaultArgs<ExtArgs>
|
licenseKey?: boolean | Prisma.Schedule$licenseKeyArgs<ExtArgs>
|
||||||
|
user?: boolean | Prisma.Schedule$userArgs<ExtArgs>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type $SchedulePayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type $SchedulePayload<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
name: "Schedule"
|
name: "Schedule"
|
||||||
objects: {
|
objects: {
|
||||||
user: Prisma.$UserPayload<ExtArgs>
|
licenseKey: Prisma.$LicenseKeyPayload<ExtArgs> | null
|
||||||
|
user: Prisma.$UserPayload<ExtArgs> | null
|
||||||
blocks: Prisma.$BlockPayload<ExtArgs>[]
|
blocks: Prisma.$BlockPayload<ExtArgs>[]
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
id: string
|
||||||
userId: string
|
licenseKeyId: string | null
|
||||||
|
userId: string | null
|
||||||
|
type: string | null
|
||||||
createdAt: Date
|
createdAt: Date
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
}, ExtArgs["result"]["schedule"]>
|
}, ExtArgs["result"]["schedule"]>
|
||||||
@@ -918,7 +1139,8 @@ readonly fields: ScheduleFieldRefs;
|
|||||||
*/
|
*/
|
||||||
export interface Prisma__ScheduleClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
|
export interface Prisma__ScheduleClient<T, Null = never, ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs, GlobalOmitOptions = {}> extends Prisma.PrismaPromise<T> {
|
||||||
readonly [Symbol.toStringTag]: "PrismaPromise"
|
readonly [Symbol.toStringTag]: "PrismaPromise"
|
||||||
user<T extends Prisma.UserDefaultArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.UserDefaultArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | Null, Null, ExtArgs, GlobalOmitOptions>
|
licenseKey<T extends Prisma.Schedule$licenseKeyArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Schedule$licenseKeyArgs<ExtArgs>>): Prisma.Prisma__LicenseKeyClient<runtime.Types.Result.GetResult<Prisma.$LicenseKeyPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
|
||||||
|
user<T extends Prisma.Schedule$userArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Schedule$userArgs<ExtArgs>>): Prisma.Prisma__UserClient<runtime.Types.Result.GetResult<Prisma.$UserPayload<ExtArgs>, T, "findUniqueOrThrow", GlobalOmitOptions> | null, null, ExtArgs, GlobalOmitOptions>
|
||||||
blocks<T extends Prisma.Schedule$blocksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Schedule$blocksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$BlockPayload<ExtArgs>, T, "findMany", GlobalOmitOptions> | Null>
|
blocks<T extends Prisma.Schedule$blocksArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.Schedule$blocksArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$BlockPayload<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.
|
||||||
@@ -950,7 +1172,9 @@ export interface Prisma__ScheduleClient<T, Null = never, ExtArgs extends runtime
|
|||||||
*/
|
*/
|
||||||
export interface ScheduleFieldRefs {
|
export interface ScheduleFieldRefs {
|
||||||
readonly id: Prisma.FieldRef<"Schedule", 'String'>
|
readonly id: Prisma.FieldRef<"Schedule", 'String'>
|
||||||
|
readonly licenseKeyId: Prisma.FieldRef<"Schedule", 'String'>
|
||||||
readonly userId: Prisma.FieldRef<"Schedule", 'String'>
|
readonly userId: Prisma.FieldRef<"Schedule", 'String'>
|
||||||
|
readonly type: Prisma.FieldRef<"Schedule", 'String'>
|
||||||
readonly createdAt: Prisma.FieldRef<"Schedule", 'DateTime'>
|
readonly createdAt: Prisma.FieldRef<"Schedule", 'DateTime'>
|
||||||
readonly updatedAt: Prisma.FieldRef<"Schedule", 'DateTime'>
|
readonly updatedAt: Prisma.FieldRef<"Schedule", 'DateTime'>
|
||||||
}
|
}
|
||||||
@@ -1353,6 +1577,44 @@ export type ScheduleDeleteManyArgs<ExtArgs extends runtime.Types.Extensions.Inte
|
|||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule.licenseKey
|
||||||
|
*/
|
||||||
|
export type Schedule$licenseKeyArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
|
/**
|
||||||
|
* Select specific fields to fetch from the LicenseKey
|
||||||
|
*/
|
||||||
|
select?: Prisma.LicenseKeySelect<ExtArgs> | null
|
||||||
|
/**
|
||||||
|
* Omit specific fields from the LicenseKey
|
||||||
|
*/
|
||||||
|
omit?: Prisma.LicenseKeyOmit<ExtArgs> | null
|
||||||
|
/**
|
||||||
|
* Choose, which related nodes to fetch as well
|
||||||
|
*/
|
||||||
|
include?: Prisma.LicenseKeyInclude<ExtArgs> | null
|
||||||
|
where?: Prisma.LicenseKeyWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule.user
|
||||||
|
*/
|
||||||
|
export type Schedule$userArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
|
/**
|
||||||
|
* Select specific fields to fetch from the User
|
||||||
|
*/
|
||||||
|
select?: Prisma.UserSelect<ExtArgs> | null
|
||||||
|
/**
|
||||||
|
* Omit specific fields from the User
|
||||||
|
*/
|
||||||
|
omit?: Prisma.UserOmit<ExtArgs> | null
|
||||||
|
/**
|
||||||
|
* Choose, which related nodes to fetch as well
|
||||||
|
*/
|
||||||
|
include?: Prisma.UserInclude<ExtArgs> | null
|
||||||
|
where?: Prisma.UserWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule.blocks
|
* Schedule.blocks
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -246,7 +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
|
schedules?: Prisma.ScheduleListRelationFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserOrderByWithRelationInput = {
|
export type UserOrderByWithRelationInput = {
|
||||||
@@ -264,7 +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
|
schedules?: Prisma.ScheduleOrderByRelationAggregateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
export type UserWhereUniqueInput = Prisma.AtLeast<{
|
||||||
@@ -285,7 +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
|
schedules?: Prisma.ScheduleListRelationFilter
|
||||||
}, "id" | "email">
|
}, "id" | "email">
|
||||||
|
|
||||||
export type UserOrderByWithAggregationInput = {
|
export type UserOrderByWithAggregationInput = {
|
||||||
@@ -332,7 +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
|
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateInput = {
|
export type UserUncheckedCreateInput = {
|
||||||
@@ -349,7 +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
|
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUpdateInput = {
|
export type UserUpdateInput = {
|
||||||
@@ -366,7 +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
|
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateInput = {
|
export type UserUncheckedUpdateInput = {
|
||||||
@@ -383,7 +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
|
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateManyInput = {
|
export type UserCreateManyInput = {
|
||||||
@@ -619,18 +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 = {
|
export type UserCreateNestedOneWithoutSchedulesInput = {
|
||||||
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
|
create?: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
|
||||||
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
|
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSchedulesInput
|
||||||
connect?: Prisma.UserWhereUniqueInput
|
connect?: Prisma.UserWhereUniqueInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUpdateOneRequiredWithoutScheduleNestedInput = {
|
export type UserUpdateOneWithoutSchedulesNestedInput = {
|
||||||
create?: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
|
create?: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
|
||||||
connectOrCreate?: Prisma.UserCreateOrConnectWithoutScheduleInput
|
connectOrCreate?: Prisma.UserCreateOrConnectWithoutSchedulesInput
|
||||||
upsert?: Prisma.UserUpsertWithoutScheduleInput
|
upsert?: Prisma.UserUpsertWithoutSchedulesInput
|
||||||
|
disconnect?: Prisma.UserWhereInput | boolean
|
||||||
|
delete?: Prisma.UserWhereInput | boolean
|
||||||
connect?: Prisma.UserWhereUniqueInput
|
connect?: Prisma.UserWhereUniqueInput
|
||||||
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutScheduleInput, Prisma.UserUpdateWithoutScheduleInput>, Prisma.UserUncheckedUpdateWithoutScheduleInput>
|
update?: Prisma.XOR<Prisma.XOR<Prisma.UserUpdateToOneWithWhereWithoutSchedulesInput, Prisma.UserUpdateWithoutSchedulesInput>, Prisma.UserUncheckedUpdateWithoutSchedulesInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutProfilesInput = {
|
export type UserCreateWithoutProfilesInput = {
|
||||||
@@ -646,7 +648,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
|
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutProfilesInput = {
|
export type UserUncheckedCreateWithoutProfilesInput = {
|
||||||
@@ -662,7 +664,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
|
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutProfilesInput = {
|
export type UserCreateOrConnectWithoutProfilesInput = {
|
||||||
@@ -694,7 +696,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
|
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutProfilesInput = {
|
export type UserUncheckedUpdateWithoutProfilesInput = {
|
||||||
@@ -710,7 +712,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
|
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutSessionsInput = {
|
export type UserCreateWithoutSessionsInput = {
|
||||||
@@ -726,7 +728,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
|
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutSessionsInput = {
|
export type UserUncheckedCreateWithoutSessionsInput = {
|
||||||
@@ -742,7 +744,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
|
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutSessionsInput = {
|
export type UserCreateOrConnectWithoutSessionsInput = {
|
||||||
@@ -774,7 +776,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
|
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutSessionsInput = {
|
export type UserUncheckedUpdateWithoutSessionsInput = {
|
||||||
@@ -790,7 +792,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
|
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutAccountsInput = {
|
export type UserCreateWithoutAccountsInput = {
|
||||||
@@ -806,7 +808,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
|
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutAccountsInput = {
|
export type UserUncheckedCreateWithoutAccountsInput = {
|
||||||
@@ -822,7 +824,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
|
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutAccountsInput = {
|
export type UserCreateOrConnectWithoutAccountsInput = {
|
||||||
@@ -854,7 +856,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
|
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutAccountsInput = {
|
export type UserUncheckedUpdateWithoutAccountsInput = {
|
||||||
@@ -870,7 +872,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
|
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutRoleInput = {
|
export type UserCreateWithoutRoleInput = {
|
||||||
@@ -886,7 +888,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
|
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutRoleInput = {
|
export type UserUncheckedCreateWithoutRoleInput = {
|
||||||
@@ -902,7 +904,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
|
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutRoleInput = {
|
export type UserCreateOrConnectWithoutRoleInput = {
|
||||||
@@ -958,7 +960,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
|
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutUserProclamationsInput = {
|
export type UserUncheckedCreateWithoutUserProclamationsInput = {
|
||||||
@@ -974,7 +976,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
|
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutUserProclamationsInput = {
|
export type UserCreateOrConnectWithoutUserProclamationsInput = {
|
||||||
@@ -1006,7 +1008,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
|
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutUserProclamationsInput = {
|
export type UserUncheckedUpdateWithoutUserProclamationsInput = {
|
||||||
@@ -1022,7 +1024,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
|
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutLicenseKeysInput = {
|
export type UserCreateWithoutLicenseKeysInput = {
|
||||||
@@ -1038,7 +1040,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
|
schedules?: Prisma.ScheduleCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutLicenseKeysInput = {
|
export type UserUncheckedCreateWithoutLicenseKeysInput = {
|
||||||
@@ -1054,7 +1056,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
|
schedules?: Prisma.ScheduleUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutLicenseKeysInput = {
|
export type UserCreateOrConnectWithoutLicenseKeysInput = {
|
||||||
@@ -1086,7 +1088,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
|
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutLicenseKeysInput = {
|
export type UserUncheckedUpdateWithoutLicenseKeysInput = {
|
||||||
@@ -1102,10 +1104,10 @@ export type UserUncheckedUpdateWithoutLicenseKeysInput = {
|
|||||||
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
accounts?: Prisma.AccountUncheckedUpdateManyWithoutUserNestedInput
|
||||||
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
profiles?: Prisma.ProfileUncheckedUpdateManyWithoutUserNestedInput
|
||||||
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUncheckedUpdateManyWithoutUserNestedInput
|
||||||
schedule?: Prisma.ScheduleUncheckedUpdateOneWithoutUserNestedInput
|
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateWithoutScheduleInput = {
|
export type UserCreateWithoutSchedulesInput = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
@@ -1121,7 +1123,7 @@ export type UserCreateWithoutScheduleInput = {
|
|||||||
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedCreateWithoutScheduleInput = {
|
export type UserUncheckedCreateWithoutSchedulesInput = {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
@@ -1137,23 +1139,23 @@ export type UserUncheckedCreateWithoutScheduleInput = {
|
|||||||
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
userProclamations?: Prisma.UserProclamationsUncheckedCreateNestedManyWithoutUserInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCreateOrConnectWithoutScheduleInput = {
|
export type UserCreateOrConnectWithoutSchedulesInput = {
|
||||||
where: Prisma.UserWhereUniqueInput
|
where: Prisma.UserWhereUniqueInput
|
||||||
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
|
create: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUpsertWithoutScheduleInput = {
|
export type UserUpsertWithoutSchedulesInput = {
|
||||||
update: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
|
update: Prisma.XOR<Prisma.UserUpdateWithoutSchedulesInput, Prisma.UserUncheckedUpdateWithoutSchedulesInput>
|
||||||
create: Prisma.XOR<Prisma.UserCreateWithoutScheduleInput, Prisma.UserUncheckedCreateWithoutScheduleInput>
|
create: Prisma.XOR<Prisma.UserCreateWithoutSchedulesInput, Prisma.UserUncheckedCreateWithoutSchedulesInput>
|
||||||
where?: Prisma.UserWhereInput
|
where?: Prisma.UserWhereInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUpdateToOneWithWhereWithoutScheduleInput = {
|
export type UserUpdateToOneWithWhereWithoutSchedulesInput = {
|
||||||
where?: Prisma.UserWhereInput
|
where?: Prisma.UserWhereInput
|
||||||
data: Prisma.XOR<Prisma.UserUpdateWithoutScheduleInput, Prisma.UserUncheckedUpdateWithoutScheduleInput>
|
data: Prisma.XOR<Prisma.UserUpdateWithoutSchedulesInput, Prisma.UserUncheckedUpdateWithoutSchedulesInput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUpdateWithoutScheduleInput = {
|
export type UserUpdateWithoutSchedulesInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -1169,7 +1171,7 @@ export type UserUpdateWithoutScheduleInput = {
|
|||||||
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
userProclamations?: Prisma.UserProclamationsUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutScheduleInput = {
|
export type UserUncheckedUpdateWithoutSchedulesInput = {
|
||||||
id?: Prisma.StringFieldUpdateOperationsInput | string
|
id?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
name?: Prisma.StringFieldUpdateOperationsInput | string
|
name?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
email?: Prisma.StringFieldUpdateOperationsInput | string
|
email?: Prisma.StringFieldUpdateOperationsInput | string
|
||||||
@@ -1208,7 +1210,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
|
schedules?: Prisma.ScheduleUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateWithoutRoleInput = {
|
export type UserUncheckedUpdateWithoutRoleInput = {
|
||||||
@@ -1224,7 +1226,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
|
schedules?: Prisma.ScheduleUncheckedUpdateManyWithoutUserNestedInput
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserUncheckedUpdateManyWithoutRoleInput = {
|
export type UserUncheckedUpdateManyWithoutRoleInput = {
|
||||||
@@ -1248,6 +1250,7 @@ export type UserCountOutputType = {
|
|||||||
profiles: number
|
profiles: number
|
||||||
licenseKeys: number
|
licenseKeys: number
|
||||||
userProclamations: number
|
userProclamations: number
|
||||||
|
schedules: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
@@ -1256,6 +1259,7 @@ export type UserCountOutputTypeSelect<ExtArgs extends runtime.Types.Extensions.I
|
|||||||
profiles?: boolean | UserCountOutputTypeCountProfilesArgs
|
profiles?: boolean | UserCountOutputTypeCountProfilesArgs
|
||||||
licenseKeys?: boolean | UserCountOutputTypeCountLicenseKeysArgs
|
licenseKeys?: boolean | UserCountOutputTypeCountLicenseKeysArgs
|
||||||
userProclamations?: boolean | UserCountOutputTypeCountUserProclamationsArgs
|
userProclamations?: boolean | UserCountOutputTypeCountUserProclamationsArgs
|
||||||
|
schedules?: boolean | UserCountOutputTypeCountSchedulesArgs
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1303,6 +1307,13 @@ export type UserCountOutputTypeCountUserProclamationsArgs<ExtArgs extends runtim
|
|||||||
where?: Prisma.UserProclamationsWhereInput
|
where?: Prisma.UserProclamationsWhereInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserCountOutputType without action
|
||||||
|
*/
|
||||||
|
export type UserCountOutputTypeCountSchedulesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
|
where?: Prisma.ScheduleWhereInput
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
export type UserSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetSelect<{
|
||||||
id?: boolean
|
id?: boolean
|
||||||
@@ -1319,7 +1330,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>
|
schedules?: boolean | Prisma.User$schedulesArgs<ExtArgs>
|
||||||
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
_count?: boolean | Prisma.UserCountOutputTypeDefaultArgs<ExtArgs>
|
||||||
}, ExtArgs["result"]["user"]>
|
}, ExtArgs["result"]["user"]>
|
||||||
|
|
||||||
@@ -1366,7 +1377,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>
|
schedules?: boolean | Prisma.User$schedulesArgs<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> = {
|
||||||
@@ -1385,7 +1396,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
|
schedules: Prisma.$SchedulePayload<ExtArgs>[]
|
||||||
}
|
}
|
||||||
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
scalars: runtime.Types.Extensions.GetPayloadResult<{
|
||||||
id: string
|
id: string
|
||||||
@@ -1796,7 +1807,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>
|
schedules<T extends Prisma.User$schedulesArgs<ExtArgs> = {}>(args?: Prisma.Subset<T, Prisma.User$schedulesArgs<ExtArgs>>): Prisma.PrismaPromise<runtime.Types.Result.GetResult<Prisma.$SchedulePayload<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.
|
||||||
@@ -2374,9 +2385,9 @@ export type User$userProclamationsArgs<ExtArgs extends runtime.Types.Extensions.
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User.schedule
|
* User.schedules
|
||||||
*/
|
*/
|
||||||
export type User$scheduleArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
export type User$schedulesArgs<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||||
/**
|
/**
|
||||||
* Select specific fields to fetch from the Schedule
|
* Select specific fields to fetch from the Schedule
|
||||||
*/
|
*/
|
||||||
@@ -2390,6 +2401,11 @@ export type User$scheduleArgs<ExtArgs extends runtime.Types.Extensions.InternalA
|
|||||||
*/
|
*/
|
||||||
include?: Prisma.ScheduleInclude<ExtArgs> | null
|
include?: Prisma.ScheduleInclude<ExtArgs> | null
|
||||||
where?: Prisma.ScheduleWhereInput
|
where?: Prisma.ScheduleWhereInput
|
||||||
|
orderBy?: Prisma.ScheduleOrderByWithRelationInput | Prisma.ScheduleOrderByWithRelationInput[]
|
||||||
|
cursor?: Prisma.ScheduleWhereUniqueInput
|
||||||
|
take?: number
|
||||||
|
skip?: number
|
||||||
|
distinct?: Prisma.ScheduleScalarFieldEnum | Prisma.ScheduleScalarFieldEnum[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,11 +3,17 @@ import {
|
|||||||
blockDuration,
|
blockDuration,
|
||||||
blockSegments,
|
blockSegments,
|
||||||
findOverlap,
|
findOverlap,
|
||||||
|
findScheduleOverlap,
|
||||||
|
fiveMinuteOptions,
|
||||||
formatTime,
|
formatTime,
|
||||||
|
hmToMinutes,
|
||||||
isOvernight,
|
isOvernight,
|
||||||
|
parseTimeInput,
|
||||||
snapMinutes,
|
snapMinutes,
|
||||||
toHm,
|
toHm,
|
||||||
validateBlock,
|
validateBlock,
|
||||||
|
validateBlockShape,
|
||||||
|
validateSchedule,
|
||||||
type BlockInput,
|
type BlockInput,
|
||||||
type BlockSibling
|
type BlockSibling
|
||||||
} from './schedule-utils';
|
} from './schedule-utils';
|
||||||
@@ -44,6 +50,52 @@ describe('toHm', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hmToMinutes', () => {
|
||||||
|
it('parses 24-hour HH:MM strings', () => {
|
||||||
|
expect(hmToMinutes('00:00')).toBe(0);
|
||||||
|
expect(hmToMinutes('07:05')).toBe(7 * 60 + 5);
|
||||||
|
expect(hmToMinutes('23:59')).toBe(23 * 60 + 59);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed or out-of-range values', () => {
|
||||||
|
expect(hmToMinutes('7:05')).toBeNull();
|
||||||
|
expect(hmToMinutes('24:00')).toBeNull();
|
||||||
|
expect(hmToMinutes('12:60')).toBeNull();
|
||||||
|
expect(hmToMinutes('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseTimeInput', () => {
|
||||||
|
it('parses tolerant 12-hour input', () => {
|
||||||
|
expect(parseTimeInput('7:05 pm')).toEqual({ ok: true, minutes: 19 * 60 + 5 });
|
||||||
|
expect(parseTimeInput('7:05PM')).toEqual({ ok: true, minutes: 19 * 60 + 5 });
|
||||||
|
expect(parseTimeInput('7pm')).toEqual({ ok: true, minutes: 19 * 60 });
|
||||||
|
expect(parseTimeInput('7:7')).toEqual({ ok: true, minutes: 7 * 60 + 7 });
|
||||||
|
expect(parseTimeInput('12:00 am')).toEqual({ ok: true, minutes: 0 });
|
||||||
|
expect(parseTimeInput('12:30 PM')).toEqual({ ok: true, minutes: 12 * 60 + 30 });
|
||||||
|
expect(parseTimeInput('11:59 pm')).toEqual({ ok: true, minutes: 23 * 60 + 59 });
|
||||||
|
expect(parseTimeInput('0:30')).toEqual({ ok: true, minutes: 30 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects ambiguous or out-of-range input', () => {
|
||||||
|
expect(parseTimeInput('13:00 PM').ok).toBe(false);
|
||||||
|
expect(parseTimeInput('7:70').ok).toBe(false);
|
||||||
|
expect(parseTimeInput('25:00').ok).toBe(false);
|
||||||
|
expect(parseTimeInput('').ok).toBe(false);
|
||||||
|
expect(parseTimeInput('noon').ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fiveMinuteOptions', () => {
|
||||||
|
it('lists every 5-minute step across the day', () => {
|
||||||
|
const options = fiveMinuteOptions();
|
||||||
|
expect(options[0]).toBe(0);
|
||||||
|
expect(options[1]).toBe(5);
|
||||||
|
expect(options[options.length - 1]).toBe(23 * 60 + 55);
|
||||||
|
expect(options).toHaveLength((24 * 60) / 5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('snapMinutes', () => {
|
describe('snapMinutes', () => {
|
||||||
it('rounds to the nearest 15 minutes and clamps to the day', () => {
|
it('rounds to the nearest 15 minutes and clamps to the day', () => {
|
||||||
expect(snapMinutes(0)).toBe(0);
|
expect(snapMinutes(0)).toBe(0);
|
||||||
@@ -233,3 +285,97 @@ describe('validateBlock', () => {
|
|||||||
expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true);
|
expect(validateBlock(input({ startMinutes: 300, endMinutes: 360 }), siblings).ok).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Schedule-wide validation (daily + weekly) ─────────────────────────
|
||||||
|
|
||||||
|
const block = (
|
||||||
|
startMinutes: number,
|
||||||
|
endMinutes: number,
|
||||||
|
day: string | null = null,
|
||||||
|
proclamationIds: string[] = ['p1']
|
||||||
|
) => ({ day, startMinutes, endMinutes, proclamationIds });
|
||||||
|
|
||||||
|
describe('validateBlockShape', () => {
|
||||||
|
it('rejects start == end', () => {
|
||||||
|
expect(validateBlockShape(block(360, 360))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: "Start and end time can't be the same."
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty block', () => {
|
||||||
|
expect(validateBlockShape(block(360, 420, null, []))).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: 'Select at least one proclamation.'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a single-minute block', () => {
|
||||||
|
expect(validateBlockShape(block(360, 361))).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateSchedule daily', () => {
|
||||||
|
it('accepts adjacent blocks (touching boundaries)', () => {
|
||||||
|
expect(validateSchedule('daily', [block(360, 420), block(420, 510)])).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a single-minute block', () => {
|
||||||
|
expect(validateSchedule('daily', [block(360, 361)])).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects overlapping blocks', () => {
|
||||||
|
expect(validateSchedule('daily', [block(360, 480), block(420, 540)]).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a midnight-crossing block that spills into an early block', () => {
|
||||||
|
// 21:00→09:00 covers [0,540) on the next day; 08:00→08:30 overlaps.
|
||||||
|
expect(validateSchedule('daily', [block(1260, 540, null), block(480, 510, null)]).ok).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a crossing block and its exact complement', () => {
|
||||||
|
expect(validateSchedule('daily', [block(1260, 540, null), block(540, 1260, null)])).toEqual({
|
||||||
|
ok: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('validateSchedule weekly', () => {
|
||||||
|
it('applies rules per day', () => {
|
||||||
|
expect(validateSchedule('weekly', [block(360, 420, 'mon'), block(360, 420, 'tue')])).toEqual({
|
||||||
|
ok: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects overlaps within the same day', () => {
|
||||||
|
expect(validateSchedule('weekly', [block(360, 480, 'wed'), block(420, 540, 'wed')]).ok).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects Sunday midnight-crossing spilling into Monday', () => {
|
||||||
|
expect(validateSchedule('weekly', [block(1380, 60, 'sun'), block(0, 120, 'mon')]).ok).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a Sunday crossing block adjacent to a Monday block', () => {
|
||||||
|
expect(validateSchedule('weekly', [block(1380, 60, 'sun'), block(60, 120, 'mon')])).toEqual({
|
||||||
|
ok: true
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a Saturday crossing spilling into Sunday', () => {
|
||||||
|
expect(validateSchedule('weekly', [block(1380, 60, 'sat'), block(30, 90, 'sun')]).ok).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findScheduleOverlap', () => {
|
||||||
|
it('returns null for gaps', () => {
|
||||||
|
expect(findScheduleOverlap('daily', [block(360, 420), block(600, 660)])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -23,6 +23,187 @@ export type BlockInput = {
|
|||||||
|
|
||||||
export type ValidationResult = { ok: true } | { ok: false; error: string };
|
export type ValidationResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
|
// ─── Schedule-wide validation (daily + weekly, midnight spillover) ─────
|
||||||
|
|
||||||
|
export type ScheduleType = 'daily' | 'weekly';
|
||||||
|
|
||||||
|
export const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'] as const;
|
||||||
|
export type WeekDay = (typeof WEEK_DAYS)[number];
|
||||||
|
|
||||||
|
/** A Block as seen by the validator. `day` is null for daily schedules. */
|
||||||
|
export type ValidationBlock = {
|
||||||
|
id?: string;
|
||||||
|
day?: string | null;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
proclamationIds?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Block length in minutes, counting a wrap past midnight as the next day. */
|
||||||
|
function blockLinearLength(block: { startMinutes: number; endMinutes: number }): number {
|
||||||
|
return block.endMinutes > block.startMinutes
|
||||||
|
? block.endMinutes - block.startMinutes
|
||||||
|
: block.endMinutes - block.startMinutes + MINUTES_PER_DAY;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Linear half-open interval `[start, end)` relative to `dayIndex`. */
|
||||||
|
function dayInterval(
|
||||||
|
block: { startMinutes: number; endMinutes: number },
|
||||||
|
dayIndex: number
|
||||||
|
): [number, number] {
|
||||||
|
const base = dayIndex * MINUTES_PER_DAY;
|
||||||
|
const start = base + block.startMinutes;
|
||||||
|
return [start, start + blockLinearLength(block)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCrossing(block: { startMinutes: number; endMinutes: number }): boolean {
|
||||||
|
return block.endMinutes < block.startMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blocksForDay(blocks: ValidationBlock[], dayIndex: number): ValidationBlock[] {
|
||||||
|
const day = WEEK_DAYS[dayIndex];
|
||||||
|
return blocks.filter((b) => (b.day ?? null) === day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First overlapping pair of Blocks in a schedule, or null. Implements the
|
||||||
|
* contract's overlap rules: touching boundaries are fine; a midnight-crossing
|
||||||
|
* Block spills into the next day (weekly Sunday→Monday) and must not overlap the
|
||||||
|
* next day's early Blocks.
|
||||||
|
*/
|
||||||
|
export function findScheduleOverlap(
|
||||||
|
type: ScheduleType,
|
||||||
|
blocks: ValidationBlock[]
|
||||||
|
): { a: ValidationBlock; b: ValidationBlock } | null {
|
||||||
|
if (type === 'daily') {
|
||||||
|
// Daily is a single day list that wraps onto itself: a crossing Block
|
||||||
|
// spills into the next day's copy of the same list. First check the
|
||||||
|
// same-day intervals, then every crossing Block against the next day.
|
||||||
|
const intervals = blocks.map((b) => ({ block: b, interval: dayInterval(b, 0) }));
|
||||||
|
const sameDay = firstPairOverlap(intervals);
|
||||||
|
if (sameDay) return sameDay;
|
||||||
|
|
||||||
|
for (const crossing of blocks.filter(isCrossing)) {
|
||||||
|
// The crossing Block's post-midnight tail, expressed in this day's
|
||||||
|
// 0-based frame (it spans [0, endMinutes)).
|
||||||
|
const spill: IntervalEntry = {
|
||||||
|
block: crossing,
|
||||||
|
interval: [0, crossing.endMinutes]
|
||||||
|
};
|
||||||
|
for (const { block, interval } of intervals) {
|
||||||
|
if (block === crossing) continue;
|
||||||
|
if (segmentsIntersect(spill.interval, interval)) {
|
||||||
|
return { a: crossing, b: block };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let d = 0; d < WEEK_DAYS.length; d++) {
|
||||||
|
const dayBlocks = blocksForDay(blocks, d);
|
||||||
|
const intervals = dayBlocks.map((b) => ({ block: b, interval: dayInterval(b, d) }));
|
||||||
|
|
||||||
|
// Same-day overlaps (a crossing Block's late segment vs the same day).
|
||||||
|
const pair = firstPairOverlap(intervals);
|
||||||
|
if (pair) return pair;
|
||||||
|
|
||||||
|
// Spillover from the previous day's crossing Blocks into this day's early
|
||||||
|
// Blocks. All spills start at midnight, so their union is [0, max(end)).
|
||||||
|
const prevIndex = (d - 1 + WEEK_DAYS.length) % WEEK_DAYS.length;
|
||||||
|
const prevCrossing = blocksForDay(blocks, prevIndex).filter(isCrossing);
|
||||||
|
for (const prev of prevCrossing) {
|
||||||
|
const spill: [number, number] = [d * MINUTES_PER_DAY, d * MINUTES_PER_DAY + prev.endMinutes];
|
||||||
|
for (const { block, interval } of intervals) {
|
||||||
|
if (segmentsIntersect(spill, interval)) {
|
||||||
|
return { a: prev, b: block };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IntervalEntry = { block: ValidationBlock; interval: [number, number] };
|
||||||
|
|
||||||
|
function firstPairOverlap(
|
||||||
|
entries: IntervalEntry[]
|
||||||
|
): { a: ValidationBlock; b: ValidationBlock } | null {
|
||||||
|
for (let i = 0; i < entries.length; i++) {
|
||||||
|
for (let j = i + 1; j < entries.length; j++) {
|
||||||
|
if (segmentsIntersect(entries[i].interval, entries[j].interval)) {
|
||||||
|
return { a: entries[i].block, b: entries[j].block };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate one Block's shape (time range + at least one Proclamation). */
|
||||||
|
export function validateBlockShape(block: ValidationBlock): ValidationResult {
|
||||||
|
const { startMinutes, endMinutes, proclamationIds } = block;
|
||||||
|
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.' };
|
||||||
|
}
|
||||||
|
if (startMinutes === endMinutes) {
|
||||||
|
return { ok: false, error: "Start and end time can't be the same." };
|
||||||
|
}
|
||||||
|
if (block.day != null && !WEEK_DAYS.includes(block.day as WeekDay)) {
|
||||||
|
return { ok: false, error: 'Pick a valid day of the week.' };
|
||||||
|
}
|
||||||
|
if (proclamationIds !== undefined) {
|
||||||
|
if (!Array.isArray(proclamationIds) || proclamationIds.length === 0) {
|
||||||
|
return { ok: false, error: 'Select at least one proclamation.' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a whole schedule. The server is authoritative; the client calls the
|
||||||
|
* same function for instant feedback.
|
||||||
|
*/
|
||||||
|
export function validateSchedule(type: ScheduleType, blocks: ValidationBlock[]): ValidationResult {
|
||||||
|
for (const block of blocks) {
|
||||||
|
const shape = validateBlockShape(block);
|
||||||
|
if (!shape.ok) return shape;
|
||||||
|
}
|
||||||
|
const overlap = findScheduleOverlap(type, blocks);
|
||||||
|
if (overlap) {
|
||||||
|
return { ok: false, error: overlapError(overlap.a) };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a candidate Block against the schedule's other Blocks (which must
|
||||||
|
* exclude the candidate when editing).
|
||||||
|
*/
|
||||||
|
export function validateBlockAgainstSchedule(
|
||||||
|
type: ScheduleType,
|
||||||
|
candidate: ValidationBlock,
|
||||||
|
siblings: ValidationBlock[]
|
||||||
|
): ValidationResult {
|
||||||
|
const shape = validateBlockShape(candidate);
|
||||||
|
if (!shape.ok) return shape;
|
||||||
|
const overlap = findScheduleOverlap(type, [...siblings, candidate]);
|
||||||
|
if (overlap) {
|
||||||
|
return { ok: false, error: overlapError(overlap.a) };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlapError(a: ValidationBlock): string {
|
||||||
|
return `This overlaps with your ${formatTime(a.startMinutes)}–${formatTime(a.endMinutes)} block.`;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMinutes(minutes: number): number {
|
function normalizeMinutes(minutes: number): number {
|
||||||
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
|
return (((Math.trunc(minutes) % MINUTES_PER_DAY) + MINUTES_PER_DAY) % MINUTES_PER_DAY) | 0;
|
||||||
}
|
}
|
||||||
@@ -45,6 +226,62 @@ export function toHm(minutes: number): string {
|
|||||||
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
return `${String(hours).padStart(2, '0')}:${String(mins).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a 24-hour `HH:MM` string into minutes from midnight. Returns null when
|
||||||
|
* the input is not a valid 24-hour time.
|
||||||
|
*/
|
||||||
|
export function hmToMinutes(hm: string): number | null {
|
||||||
|
const match = /^(\d{2}):(\d{2})$/.exec(hm.trim());
|
||||||
|
if (!match) return null;
|
||||||
|
const hours = Number(match[1]);
|
||||||
|
const minutes = Number(match[2]);
|
||||||
|
if (hours > 23 || minutes > 59) return null;
|
||||||
|
return hours * 60 + minutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimeInputResult = { ok: true; minutes: number } | { ok: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a user-typed 12-hour clock time into minutes from midnight. Tolerant of
|
||||||
|
* "7:05 pm", "7:05PM", "7pm", "7:7", "12:00 am". A period is optional; when
|
||||||
|
* present the hour must be 1-12, so "13:00 PM" is rejected. Minutes may be any
|
||||||
|
* value 00-59, not just the 5-minute steps.
|
||||||
|
*/
|
||||||
|
export function parseTimeInput(raw: string): TimeInputResult {
|
||||||
|
const text = raw.trim().toLowerCase();
|
||||||
|
if (!text) return { ok: false, error: 'Enter a time.' };
|
||||||
|
|
||||||
|
const match = /^(\d{1,2})(?::(\d{1,2}))?\s*(am|pm|a|p)?$/.exec(text);
|
||||||
|
if (!match) return { ok: false, error: 'Enter a time like 7:05 PM.' };
|
||||||
|
|
||||||
|
const hour = Number(match[1]);
|
||||||
|
const minute = match[2] === undefined ? 0 : Number(match[2]);
|
||||||
|
const period = match[3];
|
||||||
|
|
||||||
|
if (minute > 59) return { ok: false, error: 'Minutes must be 00-59.' };
|
||||||
|
|
||||||
|
if (period) {
|
||||||
|
if (hour < 1 || hour > 12) {
|
||||||
|
return { ok: false, error: 'Hour must be 1-12 when AM/PM is given.' };
|
||||||
|
}
|
||||||
|
const isPm = period === 'pm' || period === 'p';
|
||||||
|
const hours24 = (hour % 12) + (isPm ? 12 : 0);
|
||||||
|
return { ok: true, minutes: hours24 * 60 + minute };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hour > 23) return { ok: false, error: 'Hour must be 0-23.' };
|
||||||
|
return { ok: true, minutes: hour * 60 + minute };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The 5-minute clock options (00:00, 00:05, ..., 23:55) in play order. */
|
||||||
|
export function fiveMinuteOptions(): number[] {
|
||||||
|
const options: number[] = [];
|
||||||
|
for (let minutes = 0; minutes < MINUTES_PER_DAY; minutes += 5) {
|
||||||
|
options.push(minutes);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */
|
/** Snap a minute value to the nearest `SNAP_MINUTES` interval, clamped to 24h. */
|
||||||
export function snapMinutes(minutes: number): number {
|
export function snapMinutes(minutes: number): number {
|
||||||
const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;
|
const snapped = Math.round(minutes / SNAP_MINUTES) * SNAP_MINUTES;
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { PrismaClient } from '$lib/generated/prisma/client';
|
||||||
|
import pg from 'pg';
|
||||||
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
import {
|
||||||
|
getScheduleForLicense,
|
||||||
|
setScheduleType,
|
||||||
|
createBlock,
|
||||||
|
updateBlock,
|
||||||
|
copySchedule,
|
||||||
|
listBlocksForLicense,
|
||||||
|
scheduleBlocksToInput,
|
||||||
|
type OwnedLicense
|
||||||
|
} from '$lib/server/schedule';
|
||||||
|
import { buildSchedulePayload } from '$lib/server/v2-serializers';
|
||||||
|
|
||||||
|
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
const K_A1 = 'ALCE-AAAA-AAAA-0001';
|
||||||
|
const K_A2 = 'ALCE-AAAA-AAAA-0002';
|
||||||
|
const K_B1 = 'BOBB-BBBB-BBBB-0001';
|
||||||
|
|
||||||
|
const results: { id: string; pass: boolean; note: string }[] = [];
|
||||||
|
|
||||||
|
let PROCS: string[] = [];
|
||||||
|
|
||||||
|
async function licenseByKey(key: string) {
|
||||||
|
return prisma.licenseKey.findUnique({ where: { key } });
|
||||||
|
}
|
||||||
|
async function ownedFor(email: string): Promise<OwnedLicense[]> {
|
||||||
|
const user = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (!user) throw new Error('no user');
|
||||||
|
return prisma.licenseKey.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { id: true, systemName: true, key: true }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function payloadFor(schedule: Awaited<ReturnType<typeof getScheduleForLicense>>) {
|
||||||
|
const type = schedule?.type === 'daily' || schedule?.type === 'weekly' ? schedule.type : null;
|
||||||
|
return buildSchedulePayload(type, scheduleBlocksToInput(schedule?.blocks ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const [ka1, ka2, kb1] = await Promise.all([
|
||||||
|
licenseByKey(K_A1),
|
||||||
|
licenseByKey(K_A2),
|
||||||
|
licenseByKey(K_B1)
|
||||||
|
]);
|
||||||
|
await prisma.schedule.deleteMany({
|
||||||
|
where: { licenseKeyId: { in: [ka1!.id, ka2!.id, kb1!.id] } }
|
||||||
|
});
|
||||||
|
const procs = await prisma.proclamation.findMany({
|
||||||
|
where: { name: { startsWith: 'P5 ' } },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { name: 'asc' }
|
||||||
|
});
|
||||||
|
PROCS = procs.map((p) => p.id);
|
||||||
|
expect(PROCS.length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
console.log('\nP5_RESULTS ' + JSON.stringify(results));
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const enabled = process.env.P5_INTEGRATION === '1';
|
||||||
|
|
||||||
|
describe.skipIf(!enabled)('Phase 5 end-to-end service scenarios', () => {
|
||||||
|
it('e: create a daily schedule via the Portal path', async () => {
|
||||||
|
const ka1 = await licenseByKey(K_A1);
|
||||||
|
await setScheduleType(ka1!.id, 'daily', { confirmed: true });
|
||||||
|
await createBlock(ka1!.id, {
|
||||||
|
startMinutes: 7 * 60,
|
||||||
|
endMinutes: 9 * 60,
|
||||||
|
proclamationIds: [PROCS[0]],
|
||||||
|
dynamicAudio: true
|
||||||
|
});
|
||||||
|
await createBlock(ka1!.id, {
|
||||||
|
startMinutes: 18 * 60,
|
||||||
|
endMinutes: 20 * 60 + 30,
|
||||||
|
proclamationIds: [PROCS[1]],
|
||||||
|
dynamicAudio: false
|
||||||
|
});
|
||||||
|
const daily = await getScheduleForLicense(ka1!.id);
|
||||||
|
const p = payloadFor(daily);
|
||||||
|
results.push({
|
||||||
|
id: 'e',
|
||||||
|
pass: p.type === 'daily' && p.daily.length === 2,
|
||||||
|
note: `type=${p.type} blocks=${p.daily.length}`
|
||||||
|
});
|
||||||
|
expect(p.type).toBe('daily');
|
||||||
|
expect(p.daily).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('f: weekly adjacent + midnight-crossing + Sunday spillover rejection', async () => {
|
||||||
|
const kb1 = await licenseByKey(K_B1);
|
||||||
|
await setScheduleType(kb1!.id, 'weekly', { confirmed: true });
|
||||||
|
await createBlock(kb1!.id, {
|
||||||
|
day: 'sun',
|
||||||
|
startMinutes: 23 * 60,
|
||||||
|
endMinutes: 60,
|
||||||
|
proclamationIds: [PROCS[2]]
|
||||||
|
});
|
||||||
|
await createBlock(kb1!.id, {
|
||||||
|
day: 'mon',
|
||||||
|
startMinutes: 6 * 60,
|
||||||
|
endMinutes: 7 * 60,
|
||||||
|
proclamationIds: [PROCS[0]]
|
||||||
|
});
|
||||||
|
await createBlock(kb1!.id, {
|
||||||
|
day: 'mon',
|
||||||
|
startMinutes: 7 * 60,
|
||||||
|
endMinutes: 8 * 60 + 30,
|
||||||
|
proclamationIds: [PROCS[1]]
|
||||||
|
});
|
||||||
|
|
||||||
|
const p = payloadFor(await getScheduleForLicense(kb1!.id));
|
||||||
|
expect(p.type).toBe('weekly');
|
||||||
|
expect(p.weekly.sun).toHaveLength(1);
|
||||||
|
expect(p.weekly.sun[0].overnight).toBe(true);
|
||||||
|
expect(p.weekly.mon).toHaveLength(2);
|
||||||
|
expect(p.weekly.mon[0].endMinutes).toBe(p.weekly.mon[1].startMinutes);
|
||||||
|
|
||||||
|
// Sunday's 23:00->01:00 spills into Monday [00:00,01:00); a Monday 00:00-00:30 block must be rejected.
|
||||||
|
const rejected = await createBlock(kb1!.id, {
|
||||||
|
day: 'mon',
|
||||||
|
startMinutes: 0,
|
||||||
|
endMinutes: 30,
|
||||||
|
proclamationIds: [PROCS[3]]
|
||||||
|
});
|
||||||
|
const monCount = (await getScheduleForLicense(kb1!.id))!.blocks.filter(
|
||||||
|
(b) => b.day === 'mon'
|
||||||
|
).length;
|
||||||
|
results.push({
|
||||||
|
id: 'f',
|
||||||
|
pass: !rejected.ok && monCount === 2,
|
||||||
|
note: `sun overnight=${p.weekly.sun[0].overnight} mon adjacent=${p.weekly.mon[0].endMinutes === p.weekly.mon[1].startMinutes} spilloverRejected=${!rejected.ok}`
|
||||||
|
});
|
||||||
|
expect(rejected.ok).toBe(false);
|
||||||
|
expect(monCount).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('i: schedules are per-License-Key (A does not affect B)', async () => {
|
||||||
|
const [ka1, kb1] = await Promise.all([licenseByKey(K_A1), licenseByKey(K_B1)]);
|
||||||
|
const a = payloadFor(await getScheduleForLicense(ka1!.id));
|
||||||
|
const b = payloadFor(await getScheduleForLicense(kb1!.id));
|
||||||
|
results.push({
|
||||||
|
id: 'i',
|
||||||
|
pass: a.type === 'daily' && b.type === 'weekly',
|
||||||
|
note: `A=${a.type} B=${b.type}`
|
||||||
|
});
|
||||||
|
expect(a.type).toBe('daily');
|
||||||
|
expect(b.type).toBe('weekly');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('j: copy A -> second system and verify independence', async () => {
|
||||||
|
const [ka1, ka2] = await Promise.all([licenseByKey(K_A1), licenseByKey(K_A2)]);
|
||||||
|
const owned = await ownedFor('p5-alice@example.com');
|
||||||
|
const copy = await copySchedule(owned, ka1!.id, [ka2!.id]);
|
||||||
|
expect(copy.ok).toBe(true);
|
||||||
|
|
||||||
|
const ka2Before = payloadFor(await getScheduleForLicense(ka2!.id));
|
||||||
|
expect(ka2Before.type).toBe('daily');
|
||||||
|
expect(ka2Before.daily).toHaveLength(2);
|
||||||
|
|
||||||
|
const src = payloadFor(await getScheduleForLicense(ka1!.id));
|
||||||
|
expect(src.version).not.toBe(ka2Before.version);
|
||||||
|
|
||||||
|
// Edit source; copy must not change.
|
||||||
|
const blocks = await listBlocksForLicense(ka1!.id);
|
||||||
|
await updateBlock(ka1!.id, blocks[0].id, {
|
||||||
|
startMinutes: 6 * 60,
|
||||||
|
endMinutes: 7 * 60,
|
||||||
|
proclamationIds: blocks[0].proclamations.map((p) => p.proclamation.id),
|
||||||
|
dynamicAudio: true
|
||||||
|
});
|
||||||
|
const ka2After = payloadFor(await getScheduleForLicense(ka2!.id));
|
||||||
|
results.push({
|
||||||
|
id: 'j',
|
||||||
|
pass: ka2After.version === ka2Before.version && src.version !== ka2Before.version,
|
||||||
|
note: `copied daily blocks=${ka2Before.daily.length} independent=${ka2After.version === ka2Before.version}`
|
||||||
|
});
|
||||||
|
expect(ka2After.version).toBe(ka2Before.version);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
// In-memory Prisma double shared by the service and the assertions. It supports
|
||||||
|
// just the operations `copySchedule` uses, plus a shared store so we can read
|
||||||
|
// back copies and compute their Kiosk-facing payloads/ETags. It is created via
|
||||||
|
// vi.hoisted so the `vi.mock` factory (which is hoisted above imports) can see
|
||||||
|
// it.
|
||||||
|
const store = vi.hoisted(() => {
|
||||||
|
type Block = {
|
||||||
|
id: string;
|
||||||
|
scheduleId: string;
|
||||||
|
day: string | null;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamations: { proclamation: { id: string }; sortOrder: number }[];
|
||||||
|
};
|
||||||
|
type Schedule = { id: string; licenseKeyId: string | null; type: string | null };
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
schedules: [] as Schedule[],
|
||||||
|
blocks: [] as Block[],
|
||||||
|
idCounter: 0,
|
||||||
|
failOnBlockCreate: false
|
||||||
|
};
|
||||||
|
|
||||||
|
function nextId(prefix: string) {
|
||||||
|
state.idCounter += 1;
|
||||||
|
return `${prefix}-${state.idCounter}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tx = {
|
||||||
|
schedule: {
|
||||||
|
findUnique: async ({ where }: { where: { licenseKeyId?: string; id?: string } }) => {
|
||||||
|
if (where.licenseKeyId !== undefined) {
|
||||||
|
return state.schedules.find((s) => s.licenseKeyId === where.licenseKeyId) ?? null;
|
||||||
|
}
|
||||||
|
return state.schedules.find((s) => s.id === where.id) ?? null;
|
||||||
|
},
|
||||||
|
create: async ({ data }: { data: { licenseKeyId: string; type: string | null } }) => {
|
||||||
|
const row: Schedule = { id: nextId('sched'), ...data };
|
||||||
|
state.schedules.push(row);
|
||||||
|
return row;
|
||||||
|
},
|
||||||
|
update: async ({ where, data }: { where: { id: string }; data: Partial<Schedule> }) => {
|
||||||
|
const row = state.schedules.find((s) => s.id === where.id);
|
||||||
|
if (!row) throw new Error('missing schedule');
|
||||||
|
Object.assign(row, data);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
block: {
|
||||||
|
deleteMany: async ({ where }: { where: { scheduleId: string } }) => {
|
||||||
|
state.blocks = state.blocks.filter((b) => b.scheduleId !== where.scheduleId);
|
||||||
|
return { count: 0 };
|
||||||
|
},
|
||||||
|
create: async ({ data }: { data: Record<string, unknown> }) => {
|
||||||
|
if (state.failOnBlockCreate) throw new Error('db down');
|
||||||
|
const { proclamations, ...rest } = data as {
|
||||||
|
proclamations: { create: { proclamationId: string; sortOrder: number }[] };
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
const row: Block = {
|
||||||
|
id: nextId('block'),
|
||||||
|
scheduleId: rest.scheduleId as string,
|
||||||
|
day: (rest.day as string | null) ?? null,
|
||||||
|
startMinutes: rest.startMinutes as number,
|
||||||
|
endMinutes: rest.endMinutes as number,
|
||||||
|
dynamicAudio: rest.dynamicAudio as boolean,
|
||||||
|
proclamations: proclamations.create.map((p) => ({
|
||||||
|
proclamation: { id: p.proclamationId },
|
||||||
|
sortOrder: p.sortOrder
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
state.blocks.push(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const transaction = vi.fn(async (fn: (txArg: unknown) => Promise<void>) => {
|
||||||
|
const scheduleSnapshot = state.schedules.map((s) => ({ ...s }));
|
||||||
|
const blockSnapshot = state.blocks.map((b) => ({ ...b, proclamations: [...b.proclamations] }));
|
||||||
|
try {
|
||||||
|
await fn(tx);
|
||||||
|
} catch (error) {
|
||||||
|
state.schedules = scheduleSnapshot;
|
||||||
|
state.blocks = blockSnapshot;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const scheduleFindUnique = vi.fn();
|
||||||
|
|
||||||
|
return { state, tx, transaction, scheduleFindUnique, nextId };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('$lib/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
schedule: { findUnique: store.scheduleFindUnique },
|
||||||
|
$transaction: store.transaction
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { buildSchedulePayload } from './v2-serializers';
|
||||||
|
import { copySchedule, scheduleBlocksToInput, type OwnedLicense } from './schedule';
|
||||||
|
|
||||||
|
const owned: OwnedLicense[] = [
|
||||||
|
{ id: 'lk-src', systemName: 'Living Room', key: 'AAAA-AAAA-AAAA-AAAA' },
|
||||||
|
{ id: 'lk-t1', systemName: 'Kitchen', key: 'BBBB-BBBB-BBBB-BBBB' },
|
||||||
|
{ id: 'lk-t2', systemName: null, key: 'CCCC-CCCC-CCCC-CCCC' }
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Seed the source schedule and a full block list. */
|
||||||
|
function seedSource(
|
||||||
|
type: 'daily' | 'weekly',
|
||||||
|
blockSpecs: Partial<(typeof state.blocks)[number]>[]
|
||||||
|
) {
|
||||||
|
store.state.schedules = [{ id: 'sched-src', licenseKeyId: 'lk-src', type }];
|
||||||
|
store.state.blocks = blockSpecs.map((spec, i) => ({
|
||||||
|
id: `src-block-${i}`,
|
||||||
|
scheduleId: 'sched-src',
|
||||||
|
day: spec.day ?? null,
|
||||||
|
startMinutes: spec.startMinutes ?? 0,
|
||||||
|
endMinutes: spec.endMinutes ?? 60,
|
||||||
|
dynamicAudio: spec.dynamicAudio ?? true,
|
||||||
|
proclamations: spec.proclamations ?? [{ proclamation: { id: 'p1' }, sortOrder: 0 }]
|
||||||
|
}));
|
||||||
|
store.scheduleFindUnique.mockImplementation(
|
||||||
|
async ({ where }: { where: { licenseKeyId: string } }) => {
|
||||||
|
if (where.licenseKeyId !== 'lk-src') return null;
|
||||||
|
return {
|
||||||
|
...store.state.schedules.find((s) => s.licenseKeyId === 'lk-src'),
|
||||||
|
blocks: store.state.blocks
|
||||||
|
.filter((b) => b.scheduleId === 'sched-src')
|
||||||
|
.map((b) => ({ ...b, proclamations: b.proclamations }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = store.state;
|
||||||
|
|
||||||
|
function payloadFor(licenseKeyId: string) {
|
||||||
|
const schedule = state.schedules.find((s) => s.licenseKeyId === licenseKeyId) ?? null;
|
||||||
|
const scheduleBlocks = state.blocks.filter((b) => b.scheduleId === schedule?.id);
|
||||||
|
return buildSchedulePayload(
|
||||||
|
schedule?.type === 'daily' || schedule?.type === 'weekly' ? schedule.type : null,
|
||||||
|
scheduleBlocksToInput(
|
||||||
|
scheduleBlocks.map((b) => ({
|
||||||
|
id: b.id,
|
||||||
|
day: b.day,
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
dynamicAudio: b.dynamicAudio,
|
||||||
|
proclamations: b.proclamations.sort((x, y) => x.sortOrder - y.sortOrder).map((p) => p)
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.idCounter = 0;
|
||||||
|
state.failOnBlockCreate = false;
|
||||||
|
state.schedules = [];
|
||||||
|
state.blocks = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copySchedule', () => {
|
||||||
|
it('copies a daily schedule and changes the target version', async () => {
|
||||||
|
seedSource('daily', [
|
||||||
|
{ startMinutes: 360, endMinutes: 480, dynamicAudio: true },
|
||||||
|
{ startMinutes: 1080, endMinutes: 1230, dynamicAudio: false }
|
||||||
|
]);
|
||||||
|
const before = payloadFor('lk-t1'); // no schedule yet -> null payload
|
||||||
|
|
||||||
|
const result = await copySchedule(owned, 'lk-src', ['lk-t1']);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
const after = payloadFor('lk-t1');
|
||||||
|
expect(after.type).toBe('daily');
|
||||||
|
expect(after.daily).toHaveLength(2);
|
||||||
|
expect(after.daily[0].dynamicAudio).toBe(true);
|
||||||
|
expect(after.daily[1].dynamicAudio).toBe(false);
|
||||||
|
expect(after.version).not.toBe(before.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies a weekly schedule preserving day layout', async () => {
|
||||||
|
seedSource('weekly', [
|
||||||
|
{ day: 'mon', startMinutes: 360, endMinutes: 480 },
|
||||||
|
{ day: 'sun', startMinutes: 1380, endMinutes: 60 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await copySchedule(owned, 'lk-src', ['lk-t1']);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
const after = payloadFor('lk-t1');
|
||||||
|
expect(after.type).toBe('weekly');
|
||||||
|
expect(Object.keys(after.weekly).sort()).toEqual([
|
||||||
|
'fri',
|
||||||
|
'mon',
|
||||||
|
'sat',
|
||||||
|
'sun',
|
||||||
|
'thu',
|
||||||
|
'tue',
|
||||||
|
'wed'
|
||||||
|
]);
|
||||||
|
expect(after.weekly.mon).toHaveLength(1);
|
||||||
|
expect(after.weekly.sun).toHaveLength(1);
|
||||||
|
expect(after.weekly.tue).toEqual([]);
|
||||||
|
expect(after.blocks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overwrites an existing target schedule', async () => {
|
||||||
|
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
|
||||||
|
state.schedules.push({ id: 'sched-t1', licenseKeyId: 'lk-t1', type: 'daily' });
|
||||||
|
state.blocks.push({
|
||||||
|
id: 'old-block',
|
||||||
|
scheduleId: 'sched-t1',
|
||||||
|
day: null,
|
||||||
|
startMinutes: 1000,
|
||||||
|
endMinutes: 1030,
|
||||||
|
dynamicAudio: false,
|
||||||
|
proclamations: [{ proclamation: { id: 'old' }, sortOrder: 0 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await copySchedule(owned, 'lk-src', ['lk-t1']);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
const target = state.blocks.filter((b) => b.scheduleId === 'sched-t1');
|
||||||
|
expect(target).toHaveLength(1);
|
||||||
|
expect(target[0].startMinutes).toBe(360);
|
||||||
|
expect(target[0].proclamations).toEqual([{ proclamation: { id: 'p1' }, sortOrder: 0 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies to multiple targets', async () => {
|
||||||
|
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
|
||||||
|
|
||||||
|
const result = await copySchedule(owned, 'lk-src', ['lk-t1', 'lk-t2']);
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: true, copied: 2 });
|
||||||
|
expect(payloadFor('lk-t1').daily).toHaveLength(1);
|
||||||
|
expect(payloadFor('lk-t2').daily).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back every target when one fails', async () => {
|
||||||
|
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
|
||||||
|
state.failOnBlockCreate = true;
|
||||||
|
|
||||||
|
const result = await copySchedule(owned, 'lk-src', ['lk-t1', 'lk-t2']);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
// No target schedule was left behind.
|
||||||
|
expect(state.schedules.find((s) => s.licenseKeyId === 'lk-t1')).toBeUndefined();
|
||||||
|
expect(state.schedules.find((s) => s.licenseKeyId === 'lk-t2')).toBeUndefined();
|
||||||
|
expect(state.blocks.filter((b) => b.scheduleId !== 'sched-src')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a target outside the account', async () => {
|
||||||
|
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
|
||||||
|
const result = await copySchedule(owned, 'lk-src', ['lk-other']);
|
||||||
|
expect(result).toEqual({ ok: false, error: 'Target system not found.' });
|
||||||
|
expect(store.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a target equal to the source', async () => {
|
||||||
|
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
|
||||||
|
const result = await copySchedule(owned, 'lk-src', ['lk-src']);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(store.transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a source outside the account', async () => {
|
||||||
|
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
|
||||||
|
const result = await copySchedule(owned, 'lk-other', ['lk-t1']);
|
||||||
|
expect(result).toEqual({ ok: false, error: 'Source system not found.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a source with no schedule', async () => {
|
||||||
|
state.schedules = [];
|
||||||
|
state.blocks = [];
|
||||||
|
store.scheduleFindUnique.mockResolvedValue(null);
|
||||||
|
const result = await copySchedule(owned, 'lk-src', ['lk-t1']);
|
||||||
|
expect(result).toEqual({ ok: false, error: 'The source system has no schedule to copy.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns new Block IDs on the copy', async () => {
|
||||||
|
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
|
||||||
|
await copySchedule(owned, 'lk-src', ['lk-t1']);
|
||||||
|
|
||||||
|
const copied = state.blocks.filter((b) => b.scheduleId !== 'sched-src');
|
||||||
|
expect(copied).toHaveLength(1);
|
||||||
|
expect(copied[0].id).not.toBe('src-block-0');
|
||||||
|
expect(copied[0].id).toMatch(/^block-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces independent copies (source edits do not affect targets)', async () => {
|
||||||
|
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
|
||||||
|
await copySchedule(owned, 'lk-src', ['lk-t1']);
|
||||||
|
const targetVersion = payloadFor('lk-t1').version;
|
||||||
|
|
||||||
|
// Edit the source in place.
|
||||||
|
state.blocks = state.blocks.map((b) =>
|
||||||
|
b.scheduleId === 'sched-src' ? { ...b, endMinutes: 999 } : b
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(payloadFor('lk-t1').version).toBe(targetVersion);
|
||||||
|
expect(payloadFor('lk-src').version).not.toBe(targetVersion);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a target list with no targets', async () => {
|
||||||
|
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
|
||||||
|
const result = await copySchedule(owned, 'lk-src', []);
|
||||||
|
expect(result).toEqual({ ok: false, error: 'Pick at least one system to copy to.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes the target ETag to match the served payload version', async () => {
|
||||||
|
seedSource('daily', [{ startMinutes: 360, endMinutes: 480 }]);
|
||||||
|
await copySchedule(owned, 'lk-src', ['lk-t1']);
|
||||||
|
|
||||||
|
const payload = payloadFor('lk-t1');
|
||||||
|
const etag = `"${payload.version}"`;
|
||||||
|
const expected = `"${createHash('sha1')
|
||||||
|
.update(JSON.stringify({ type: payload.type, daily: payload.daily, weekly: payload.weekly }))
|
||||||
|
.digest('hex')}"`;
|
||||||
|
expect(etag).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copied payload is consumed like any Kiosk schedule', () => {
|
||||||
|
const readFixture = (name: string) =>
|
||||||
|
JSON.parse(readFileSync(resolve(process.cwd(), 'docs/fixtures', name), 'utf8'));
|
||||||
|
|
||||||
|
it('matches the daily fixture shape after copy (IDs differ)', async () => {
|
||||||
|
const fixture = readFixture('schedule-daily.json');
|
||||||
|
// Seed the source from the daily fixture content.
|
||||||
|
seedSource(
|
||||||
|
'daily',
|
||||||
|
fixture.daily.map((b: Record<string, unknown>) => ({
|
||||||
|
day: null,
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
dynamicAudio: b.dynamicAudio,
|
||||||
|
proclamations: (b.proclamationIds as string[]).map((id, i) => ({
|
||||||
|
proclamation: { id },
|
||||||
|
sortOrder: i
|
||||||
|
}))
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await copySchedule(owned, 'lk-src', ['lk-t1']);
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
|
||||||
|
const payload = payloadFor('lk-t1');
|
||||||
|
expect(payload.type).toBe('daily');
|
||||||
|
// Same times/audio/ids as the fixture; only block IDs differ.
|
||||||
|
expect(
|
||||||
|
payload.daily.map((b) => ({
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
dynamicAudio: b.dynamicAudio,
|
||||||
|
proclamationIds: b.proclamationIds
|
||||||
|
}))
|
||||||
|
).toEqual(
|
||||||
|
fixture.daily.map((b: Record<string, unknown>) => ({
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
dynamicAudio: b.dynamicAudio,
|
||||||
|
proclamationIds: b.proclamationIds
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
expect(payload.blocks).toEqual(payload.daily);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the weekly fixture day layout after copy', async () => {
|
||||||
|
const fixture = readFixture('schedule-weekly.json');
|
||||||
|
const specs: Record<string, unknown>[] = [];
|
||||||
|
for (const [day, list] of Object.entries(
|
||||||
|
fixture.weekly as Record<string, Record<string, unknown>[]>
|
||||||
|
)) {
|
||||||
|
for (const b of list) {
|
||||||
|
specs.push({
|
||||||
|
day,
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
dynamicAudio: b.dynamicAudio,
|
||||||
|
proclamations: (b.proclamationIds as string[]).map((id, i) => ({
|
||||||
|
proclamation: { id },
|
||||||
|
sortOrder: i
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seedSource('weekly', specs);
|
||||||
|
|
||||||
|
await copySchedule(owned, 'lk-src', ['lk-t1']);
|
||||||
|
const payload = payloadFor('lk-t1');
|
||||||
|
|
||||||
|
expect(payload.type).toBe('weekly');
|
||||||
|
expect(payload.blocks).toEqual([]);
|
||||||
|
const nonEmptyDays = Object.entries(payload.weekly)
|
||||||
|
.filter(([, list]) => list.length > 0)
|
||||||
|
.map(([day]) => day)
|
||||||
|
.sort();
|
||||||
|
const fixtureDays = Object.entries(fixture.weekly as Record<string, unknown[]>)
|
||||||
|
.filter(([, list]) => list.length > 0)
|
||||||
|
.map(([day]) => day)
|
||||||
|
.sort();
|
||||||
|
expect(nonEmptyDays).toEqual(fixtureDays);
|
||||||
|
});
|
||||||
|
});
|
||||||
+108
-55
@@ -2,27 +2,40 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|||||||
|
|
||||||
vi.mock('$lib/db', () => ({
|
vi.mock('$lib/db', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
schedule: { findUnique: vi.fn(), create: vi.fn() },
|
schedule: { findUnique: vi.fn(), create: vi.fn(), update: vi.fn() },
|
||||||
|
licenseKey: { findUnique: vi.fn() },
|
||||||
block: {
|
block: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
findFirst: vi.fn(),
|
findFirst: vi.fn(),
|
||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
|
createMany: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn()
|
delete: vi.fn(),
|
||||||
}
|
deleteMany: vi.fn()
|
||||||
|
},
|
||||||
|
blockProclamations: { createMany: vi.fn() }
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
import { createBlock, deleteBlock, getOrCreateSchedule, listBlocks, updateBlock } from './schedule';
|
import {
|
||||||
|
createBlock,
|
||||||
|
deleteBlock,
|
||||||
|
getOrCreateScheduleForLicense,
|
||||||
|
listBlocksForLicense,
|
||||||
|
setScheduleType,
|
||||||
|
updateBlock
|
||||||
|
} from './schedule';
|
||||||
|
|
||||||
const scheduleFindUnique = prisma.schedule.findUnique as ReturnType<typeof vi.fn>;
|
const scheduleFindUnique = prisma.schedule.findUnique as ReturnType<typeof vi.fn>;
|
||||||
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
|
const scheduleCreate = prisma.schedule.create as ReturnType<typeof vi.fn>;
|
||||||
|
const scheduleUpdate = prisma.schedule.update as ReturnType<typeof vi.fn>;
|
||||||
const blockFindMany = prisma.block.findMany 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 blockFindFirst = prisma.block.findFirst as ReturnType<typeof vi.fn>;
|
||||||
const blockCreate = prisma.block.create 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 blockUpdate = prisma.block.update as ReturnType<typeof vi.fn>;
|
||||||
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
|
const blockDelete = prisma.block.delete as ReturnType<typeof vi.fn>;
|
||||||
|
const blockDeleteMany = prisma.block.deleteMany as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
const validInput = {
|
const validInput = {
|
||||||
startMinutes: 6 * 60,
|
startMinutes: 6 * 60,
|
||||||
@@ -34,52 +47,95 @@ beforeEach(() => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getOrCreateSchedule', () => {
|
describe('getOrCreateScheduleForLicense', () => {
|
||||||
it('returns the existing schedule', async () => {
|
it('returns the existing schedule', async () => {
|
||||||
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
|
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' });
|
||||||
|
|
||||||
const result = await getOrCreateSchedule('u1');
|
const result = await getOrCreateScheduleForLicense('lk1');
|
||||||
|
|
||||||
expect(result).toEqual({ id: 's1', userId: 'u1' });
|
expect(result).toEqual({ id: 's1', licenseKeyId: 'lk1' });
|
||||||
expect(scheduleCreate).not.toHaveBeenCalled();
|
expect(scheduleCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates the singleton schedule when missing', async () => {
|
it('creates a schedule keyed by license when missing', async () => {
|
||||||
scheduleFindUnique.mockResolvedValue(null);
|
scheduleFindUnique.mockResolvedValue(null);
|
||||||
scheduleCreate.mockResolvedValue({ id: 's1', userId: 'u1' });
|
scheduleCreate.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1' });
|
||||||
|
|
||||||
await getOrCreateSchedule('u1');
|
await getOrCreateScheduleForLicense('lk1');
|
||||||
|
|
||||||
expect(scheduleCreate).toHaveBeenCalledWith({ data: { userId: 'u1' } });
|
expect(scheduleCreate).toHaveBeenCalledWith({ data: { licenseKeyId: 'lk1' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('listBlocks', () => {
|
describe('listBlocksForLicense', () => {
|
||||||
it('scopes to the user and includes proclamations with frequencies', async () => {
|
it('scopes to the license key', async () => {
|
||||||
blockFindMany.mockResolvedValue([]);
|
blockFindMany.mockResolvedValue([]);
|
||||||
|
|
||||||
await listBlocks('u1');
|
await listBlocksForLicense('lk1');
|
||||||
|
|
||||||
expect(blockFindMany).toHaveBeenCalledWith(
|
expect(blockFindMany).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
where: { schedule: { userId: 'u1' } },
|
where: { schedule: { licenseKeyId: 'lk1' } },
|
||||||
orderBy: { startMinutes: 'asc' },
|
|
||||||
include: expect.objectContaining({ proclamations: expect.anything() })
|
include: expect.objectContaining({ proclamations: expect.anything() })
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setScheduleType', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('set type when empty', async () => {
|
||||||
|
blockFindMany.mockResolvedValue([]);
|
||||||
|
scheduleUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await setScheduleType('lk1', 'daily');
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(scheduleUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 's1' },
|
||||||
|
data: { type: 'daily' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires confirmation to switch a populated schedule', async () => {
|
||||||
|
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
|
||||||
|
blockFindMany.mockResolvedValue([
|
||||||
|
{ id: 'b1', startMinutes: 0, endMinutes: 60, dynamicAudio: true, proclamations: [] }
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await setScheduleType('lk1', 'weekly');
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, error: 'confirm' });
|
||||||
|
expect(scheduleUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearing to null deletes the blocks', async () => {
|
||||||
|
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
|
||||||
|
blockFindMany.mockResolvedValue([]);
|
||||||
|
blockDeleteMany.mockResolvedValue({ count: 0 });
|
||||||
|
scheduleUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
|
const result = await setScheduleType('lk1', null, { confirmed: true });
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(blockDeleteMany).toHaveBeenCalledWith({ where: { scheduleId: 's1' } });
|
||||||
|
expect(scheduleUpdate).toHaveBeenCalledWith({ where: { id: 's1' }, data: { type: null } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('createBlock', () => {
|
describe('createBlock', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
scheduleFindUnique.mockResolvedValue({ id: 's1', userId: 'u1' });
|
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a block with deduped proclamations in order', async () => {
|
it('creates a block with deduped proclamations in order', async () => {
|
||||||
blockFindMany.mockResolvedValue([]);
|
blockFindMany.mockResolvedValue([]);
|
||||||
blockCreate.mockResolvedValue({ id: 'new-block' });
|
blockCreate.mockResolvedValue({ id: 'new-block' });
|
||||||
|
|
||||||
const result = await createBlock('u1', {
|
const result = await createBlock('lk1', {
|
||||||
...validInput,
|
...validInput,
|
||||||
proclamationIds: ['p1', 'p1', 'p2']
|
proclamationIds: ['p1', 'p1', 'p2']
|
||||||
});
|
});
|
||||||
@@ -88,9 +144,10 @@ describe('createBlock', () => {
|
|||||||
expect(blockCreate).toHaveBeenCalledWith({
|
expect(blockCreate).toHaveBeenCalledWith({
|
||||||
data: {
|
data: {
|
||||||
scheduleId: 's1',
|
scheduleId: 's1',
|
||||||
|
day: null,
|
||||||
startMinutes: validInput.startMinutes,
|
startMinutes: validInput.startMinutes,
|
||||||
endMinutes: validInput.endMinutes,
|
endMinutes: validInput.endMinutes,
|
||||||
dynamicAudio: false,
|
dynamicAudio: true,
|
||||||
proclamations: {
|
proclamations: {
|
||||||
create: [
|
create: [
|
||||||
{ proclamationId: 'p1', sortOrder: 0 },
|
{ proclamationId: 'p1', sortOrder: 0 },
|
||||||
@@ -101,37 +158,21 @@ describe('createBlock', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('persists the dynamic audio flag', async () => {
|
it('rejects when no type is set', async () => {
|
||||||
blockFindMany.mockResolvedValue([]);
|
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: null });
|
||||||
blockCreate.mockResolvedValue({ id: 'new-block' });
|
|
||||||
|
|
||||||
await createBlock('u1', { ...validInput, dynamicAudio: true });
|
const result = await createBlock('lk1', validInput);
|
||||||
|
|
||||||
expect(blockCreate).toHaveBeenCalledWith(
|
expect(result).toEqual({ ok: false, error: 'Set a schedule type first.' });
|
||||||
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();
|
expect(blockCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an overlapping block with a specific message', async () => {
|
it('rejects an overlapping block with a specific message', async () => {
|
||||||
blockFindMany.mockResolvedValue([{ id: 'a', startMinutes: 7 * 60, endMinutes: 9 * 60 }]);
|
blockFindMany.mockResolvedValue([
|
||||||
|
{ id: 'a', day: null, startMinutes: 7 * 60, endMinutes: 9 * 60, proclamations: [] }
|
||||||
|
]);
|
||||||
|
|
||||||
const result = await createBlock('u1', validInput);
|
const result = await createBlock('lk1', validInput);
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -139,13 +180,22 @@ describe('createBlock', () => {
|
|||||||
});
|
});
|
||||||
expect(blockCreate).not.toHaveBeenCalled();
|
expect(blockCreate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects an empty block before creating', async () => {
|
||||||
|
blockFindMany.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const result = await createBlock('lk1', { ...validInput, proclamationIds: [] });
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: false, error: 'Select at least one proclamation.' });
|
||||||
|
expect(blockCreate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateBlock', () => {
|
describe('updateBlock', () => {
|
||||||
it('returns not found when the block does not belong to the user', async () => {
|
it('returns not found when the block does not belong to the license', async () => {
|
||||||
blockFindFirst.mockResolvedValue(null);
|
blockFindFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
const result = await updateBlock('u1', 'missing', validInput);
|
const result = await updateBlock('lk1', 'missing', validInput);
|
||||||
|
|
||||||
expect(result).toEqual({ ok: false, error: 'Block not found.' });
|
expect(result).toEqual({ ok: false, error: 'Block not found.' });
|
||||||
expect(blockUpdate).not.toHaveBeenCalled();
|
expect(blockUpdate).not.toHaveBeenCalled();
|
||||||
@@ -153,22 +203,25 @@ describe('updateBlock', () => {
|
|||||||
|
|
||||||
it('replaces proclamations and excludes itself from overlap checks', async () => {
|
it('replaces proclamations and excludes itself from overlap checks', async () => {
|
||||||
blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' });
|
blockFindFirst.mockResolvedValue({ id: 'b1', scheduleId: 's1' });
|
||||||
|
scheduleFindUnique.mockResolvedValue({ id: 's1', licenseKeyId: 'lk1', type: 'daily' });
|
||||||
blockFindMany.mockResolvedValue([]);
|
blockFindMany.mockResolvedValue([]);
|
||||||
blockUpdate.mockResolvedValue({ id: 'b1' });
|
blockUpdate.mockResolvedValue({ id: 'b1' });
|
||||||
|
|
||||||
const result = await updateBlock('u1', 'b1', validInput);
|
const result = await updateBlock('lk1', 'b1', validInput);
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true, id: 'b1' });
|
expect(result).toEqual({ ok: true, id: 'b1' });
|
||||||
expect(blockFindMany).toHaveBeenCalledWith({
|
expect(blockFindMany).toHaveBeenCalledWith(
|
||||||
where: { scheduleId: 's1', id: { not: 'b1' } },
|
expect.objectContaining({
|
||||||
select: { id: true, startMinutes: true, endMinutes: true }
|
where: { scheduleId: 's1', id: { not: 'b1' } }
|
||||||
});
|
})
|
||||||
|
);
|
||||||
expect(blockUpdate).toHaveBeenCalledWith({
|
expect(blockUpdate).toHaveBeenCalledWith({
|
||||||
where: { id: 'b1' },
|
where: { id: 'b1' },
|
||||||
data: {
|
data: {
|
||||||
|
day: null,
|
||||||
startMinutes: validInput.startMinutes,
|
startMinutes: validInput.startMinutes,
|
||||||
endMinutes: validInput.endMinutes,
|
endMinutes: validInput.endMinutes,
|
||||||
dynamicAudio: false,
|
dynamicAudio: true,
|
||||||
proclamations: {
|
proclamations: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
create: [{ proclamationId: 'p1', sortOrder: 0 }]
|
create: [{ proclamationId: 'p1', sortOrder: 0 }]
|
||||||
@@ -179,10 +232,10 @@ describe('updateBlock', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteBlock', () => {
|
describe('deleteBlock', () => {
|
||||||
it('returns not found when the block does not belong to the user', async () => {
|
it('returns not found when the block does not belong to the license', async () => {
|
||||||
blockFindFirst.mockResolvedValue(null);
|
blockFindFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
const result = await deleteBlock('u1', 'missing');
|
const result = await deleteBlock('lk1', 'missing');
|
||||||
|
|
||||||
expect(result).toEqual({ ok: false, error: 'Block not found.' });
|
expect(result).toEqual({ ok: false, error: 'Block not found.' });
|
||||||
expect(blockDelete).not.toHaveBeenCalled();
|
expect(blockDelete).not.toHaveBeenCalled();
|
||||||
@@ -192,7 +245,7 @@ describe('deleteBlock', () => {
|
|||||||
blockFindFirst.mockResolvedValue({ id: 'b1' });
|
blockFindFirst.mockResolvedValue({ id: 'b1' });
|
||||||
blockDelete.mockResolvedValue({ id: 'b1' });
|
blockDelete.mockResolvedValue({ id: 'b1' });
|
||||||
|
|
||||||
const result = await deleteBlock('u1', 'b1');
|
const result = await deleteBlock('lk1', 'b1');
|
||||||
|
|
||||||
expect(result).toEqual({ ok: true, id: 'b1' });
|
expect(result).toEqual({ ok: true, id: 'b1' });
|
||||||
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
|
expect(blockDelete).toHaveBeenCalledWith({ where: { id: 'b1' } });
|
||||||
|
|||||||
+424
-43
@@ -1,73 +1,288 @@
|
|||||||
// Schedule service layer. Owns the database orchestration and enforcement of
|
// Schedule service layer. Owns the database orchestration and enforcement of
|
||||||
// the Block business rules (at most 4 per user, at least one Proclamation,
|
// the Block business rules. Pure validation lives in `$lib/schedule-utils` so
|
||||||
// no overnight ranges, no overlaps). Pure validation lives in
|
// the client can reuse the exact same rules.
|
||||||
// `$lib/schedule-utils` so the client modal can reuse it.
|
//
|
||||||
|
// A Schedule belongs to exactly one License Key. `type` is "daily" | "weekly" |
|
||||||
|
// null (null = no schedule). Daily Blocks have day=null; weekly Blocks carry a
|
||||||
|
// week day. Times are wall-clock minutes from midnight.
|
||||||
|
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
import { validateBlock, type BlockInput } from '$lib/schedule-utils';
|
import {
|
||||||
|
MAX_BLOCKS,
|
||||||
|
WEEK_DAYS,
|
||||||
|
validateBlockAgainstSchedule,
|
||||||
|
validateSchedule,
|
||||||
|
type ScheduleType,
|
||||||
|
type ValidationBlock
|
||||||
|
} from '$lib/schedule-utils';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
MAX_BLOCKS,
|
MAX_BLOCKS,
|
||||||
MINUTES_PER_DAY,
|
MINUTES_PER_DAY,
|
||||||
SNAP_MINUTES,
|
SNAP_MINUTES,
|
||||||
MINUTE_STEP,
|
MINUTE_STEP,
|
||||||
|
WEEK_DAYS,
|
||||||
formatTime,
|
formatTime,
|
||||||
toHm,
|
toHm,
|
||||||
snapMinutes,
|
snapMinutes,
|
||||||
findOverlap,
|
findOverlap,
|
||||||
validateBlock
|
findScheduleOverlap,
|
||||||
|
validateBlock,
|
||||||
|
validateBlockAgainstSchedule,
|
||||||
|
validateBlockShape,
|
||||||
|
validateSchedule
|
||||||
} from '$lib/schedule-utils';
|
} from '$lib/schedule-utils';
|
||||||
export type { BlockInput, BlockSibling, ValidationResult } from '$lib/schedule-utils';
|
export type {
|
||||||
|
BlockInput as LegacyBlockInput,
|
||||||
|
BlockSibling,
|
||||||
|
ValidationResult,
|
||||||
|
ValidationBlock,
|
||||||
|
ScheduleType
|
||||||
|
} from '$lib/schedule-utils';
|
||||||
|
|
||||||
|
export type BlockInput = {
|
||||||
|
day?: string | null;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
proclamationIds: string[];
|
||||||
|
dynamicAudio?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string };
|
export type BlockMutationResult = { ok: true; id?: string } | { ok: false; error: string };
|
||||||
|
export type TypeMutationResult = { ok: true } | { ok: false; error: string };
|
||||||
|
|
||||||
function uniqueIds(ids: string[]): string[] {
|
function uniqueIds(ids: string[]): string[] {
|
||||||
return [...new Set(ids.filter((id) => typeof id === 'string' && id.length > 0))];
|
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. */
|
function dynamicAudioOrDefault(input: BlockInput): boolean {
|
||||||
export async function getOrCreateSchedule(userId: string) {
|
return input.dynamicAudio !== false;
|
||||||
const existing = await prisma.schedule.findUnique({ where: { userId } });
|
}
|
||||||
|
|
||||||
|
function isScheduleType(value: unknown): value is ScheduleType {
|
||||||
|
return value === 'daily' || value === 'weekly';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise the day for a schedule type: daily always uses null. */
|
||||||
|
function normaliseDay(type: ScheduleType | null, day: string | null | undefined): string | null {
|
||||||
|
if (type !== 'weekly') return null;
|
||||||
|
return day ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedules only need a Proclamation's identity and the labels the portal
|
||||||
|
// shows. Selecting explicitly (rather than `include`) avoids pulling every
|
||||||
|
// Proclamation column — notably the potentially large `content` — on each load.
|
||||||
|
const blockInclude = {
|
||||||
|
proclamations: {
|
||||||
|
orderBy: { sortOrder: 'asc' as const },
|
||||||
|
select: {
|
||||||
|
sortOrder: true,
|
||||||
|
proclamation: { select: { id: true, name: true, categoryId: true, groupId: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ensure the License Key has a Schedule row. New rows start with type null. */
|
||||||
|
export async function getOrCreateScheduleForLicense(licenseKeyId: string) {
|
||||||
|
const existing = await prisma.schedule.findUnique({ where: { licenseKeyId } });
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
return prisma.schedule.create({ data: { userId } });
|
return prisma.schedule.create({ data: { licenseKeyId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Schedule (with Blocks and Proclamations) for a License Key, or null. */
|
||||||
|
export async function getScheduleForLicense(licenseKeyId: string) {
|
||||||
|
return prisma.schedule.findUnique({
|
||||||
|
where: { licenseKeyId },
|
||||||
|
include: { blocks: { include: blockInclude } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Schedule for a License Key string, or null. */
|
||||||
|
export async function getScheduleForLicenseKey(licenseKey: string) {
|
||||||
|
const license = await prisma.licenseKey.findUnique({
|
||||||
|
where: { key: licenseKey },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
if (!license) return { license: null, schedule: null };
|
||||||
|
const schedule = await getScheduleForLicense(license.id);
|
||||||
|
return { license, schedule };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blocks for a License Key's schedule, ordered by day then start. */
|
||||||
|
export async function listBlocksForLicense(licenseKeyId: string) {
|
||||||
|
return prisma.block.findMany({
|
||||||
|
where: { schedule: { licenseKeyId } },
|
||||||
|
orderBy: [{ day: 'asc' }, { startMinutes: 'asc' }],
|
||||||
|
include: blockInclude
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toValidationBlock(block: {
|
||||||
|
id?: string;
|
||||||
|
day: string | null;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
proclamationIds?: string[];
|
||||||
|
}): ValidationBlock {
|
||||||
|
return {
|
||||||
|
id: block.id,
|
||||||
|
day: block.day,
|
||||||
|
startMinutes: block.startMinutes,
|
||||||
|
endMinutes: block.endMinutes,
|
||||||
|
proclamationIds: block.proclamationIds
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user's Blocks with their selected Proclamations (and frequencies for
|
* Change (or clear) a Schedule's type. daily→weekly prefills all seven days
|
||||||
* canonical-id mapping), ordered by start time.
|
* with the daily Blocks; weekly→daily and clearing start empty. Returns an
|
||||||
|
* error result when confirmation is implicitly required but not given.
|
||||||
*/
|
*/
|
||||||
export async function listBlocks(userId: string) {
|
export async function setScheduleType(
|
||||||
return prisma.block.findMany({
|
licenseKeyId: string,
|
||||||
where: { schedule: { userId } },
|
type: ScheduleType | null,
|
||||||
orderBy: { startMinutes: 'asc' },
|
opts: { confirmed?: boolean } = {}
|
||||||
include: {
|
): Promise<TypeMutationResult> {
|
||||||
proclamations: {
|
if (type !== null && !isScheduleType(type)) {
|
||||||
orderBy: { sortOrder: 'asc' },
|
return { ok: false, error: 'Pick a schedule type.' };
|
||||||
include: {
|
}
|
||||||
proclamation: { include: { solfeggioFrequency: true } }
|
|
||||||
}
|
const schedule = await getOrCreateScheduleForLicense(licenseKeyId);
|
||||||
|
if (schedule.type === type) return { ok: true };
|
||||||
|
|
||||||
|
const existing = await prisma.block.findMany({
|
||||||
|
where: { scheduleId: schedule.id },
|
||||||
|
include: { proclamations: { orderBy: { sortOrder: 'asc' } } }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Changing an existing, populated schedule requires confirmation.
|
||||||
|
if (
|
||||||
|
(schedule.type === 'daily' || schedule.type === 'weekly') &&
|
||||||
|
type !== null &&
|
||||||
|
!opts.confirmed
|
||||||
|
) {
|
||||||
|
return { ok: false, error: 'confirm' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'weekly' && schedule.type === 'daily' && existing.length > 0) {
|
||||||
|
// Prefill all seven days with a copy of the daily Blocks.
|
||||||
|
await prisma.block.createMany({
|
||||||
|
data: existing.flatMap((block) =>
|
||||||
|
WEEK_DAYS.map((day) => ({
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
day,
|
||||||
|
startMinutes: block.startMinutes,
|
||||||
|
endMinutes: block.endMinutes,
|
||||||
|
dynamicAudio: block.dynamicAudio
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
});
|
||||||
|
const clones = await prisma.block.findMany({
|
||||||
|
where: { scheduleId: schedule.id, day: { not: null } }
|
||||||
|
});
|
||||||
|
for (const clone of clones) {
|
||||||
|
const source = existing.find(
|
||||||
|
(b) =>
|
||||||
|
b.startMinutes === clone.startMinutes &&
|
||||||
|
b.endMinutes === clone.endMinutes &&
|
||||||
|
b.dynamicAudio === clone.dynamicAudio
|
||||||
|
);
|
||||||
|
if (source) {
|
||||||
|
await prisma.blockProclamations.createMany({
|
||||||
|
data: source.proclamations.map((p, index) => ({
|
||||||
|
blockId: clone.id,
|
||||||
|
proclamationId: p.proclamationId,
|
||||||
|
sortOrder: index
|
||||||
|
})),
|
||||||
|
skipDuplicates: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
} else if (type !== 'weekly') {
|
||||||
|
// weekly→daily, daily→null, weekly→null: clear the Blocks.
|
||||||
|
await prisma.block.deleteMany({ where: { scheduleId: schedule.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.schedule.update({ where: { id: schedule.id }, data: { type } });
|
||||||
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createBlock(userId: string, input: BlockInput): Promise<BlockMutationResult> {
|
async function ensureValid(
|
||||||
const schedule = await getOrCreateSchedule(userId);
|
licenseKeyId: string,
|
||||||
const siblings = await prisma.block.findMany({
|
day: string | null,
|
||||||
where: { scheduleId: schedule.id },
|
candidate: BlockInput,
|
||||||
select: { id: true, startMinutes: true, endMinutes: true }
|
excludeBlockId?: string
|
||||||
|
): Promise<BlockMutationResult & { scheduleId?: string; type?: ScheduleType | null }> {
|
||||||
|
const schedule = await getOrCreateScheduleForLicense(licenseKeyId);
|
||||||
|
const type: ScheduleType | null = isScheduleType(schedule.type) ? schedule.type : null;
|
||||||
|
if (type === null) {
|
||||||
|
return { ok: false, error: 'Set a schedule type first.' };
|
||||||
|
}
|
||||||
|
if (type === 'weekly' && (day === null || !(WEEK_DAYS as readonly string[]).includes(day))) {
|
||||||
|
return { ok: false, error: 'Pick a valid day of the week.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.block.findMany({
|
||||||
|
where: {
|
||||||
|
scheduleId: schedule.id,
|
||||||
|
...(excludeBlockId ? { id: { not: excludeBlockId } } : {})
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
day: true,
|
||||||
|
startMinutes: true,
|
||||||
|
endMinutes: true,
|
||||||
|
proclamations: { select: { proclamationId: true } }
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const validation = validateBlock(input, siblings, { isCreate: true });
|
const siblings: ValidationBlock[] = existing.map((b) =>
|
||||||
|
toValidationBlock({
|
||||||
|
id: b.id,
|
||||||
|
day: b.day,
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
proclamationIds: b.proclamations.map((p) => p.proclamationId)
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const candidateBlock = toValidationBlock({
|
||||||
|
day,
|
||||||
|
startMinutes: candidate.startMinutes,
|
||||||
|
endMinutes: candidate.endMinutes,
|
||||||
|
proclamationIds: uniqueIds(candidate.proclamationIds)
|
||||||
|
});
|
||||||
|
|
||||||
|
const validation = validateBlockAgainstSchedule(type, candidateBlock, siblings);
|
||||||
if (!validation.ok) return validation;
|
if (!validation.ok) return validation;
|
||||||
|
|
||||||
|
const sameDayCount = siblings.filter((b) => normaliseDay(type, b.day) === day).length;
|
||||||
|
if (!excludeBlockId && sameDayCount >= MAX_BLOCKS) {
|
||||||
|
return { ok: false, error: `You can have at most ${MAX_BLOCKS} blocks per day.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, scheduleId: schedule.id, type };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createBlock(
|
||||||
|
licenseKeyId: string,
|
||||||
|
input: BlockInput
|
||||||
|
): Promise<BlockMutationResult> {
|
||||||
|
const schedule = await getOrCreateScheduleForLicense(licenseKeyId);
|
||||||
|
const type: ScheduleType | null = isScheduleType(schedule.type) ? schedule.type : null;
|
||||||
|
const day = normaliseDay(type, input.day);
|
||||||
|
|
||||||
|
const checked = await ensureValid(licenseKeyId, day, input);
|
||||||
|
if (!checked.ok) return { ok: false, error: checked.error };
|
||||||
|
|
||||||
const proclamationIds = uniqueIds(input.proclamationIds);
|
const proclamationIds = uniqueIds(input.proclamationIds);
|
||||||
const block = await prisma.block.create({
|
const block = await prisma.block.create({
|
||||||
data: {
|
data: {
|
||||||
scheduleId: schedule.id,
|
scheduleId: schedule.id,
|
||||||
|
day,
|
||||||
startMinutes: input.startMinutes,
|
startMinutes: input.startMinutes,
|
||||||
endMinutes: input.endMinutes,
|
endMinutes: input.endMinutes,
|
||||||
dynamicAudio: input.dynamicAudio === true,
|
dynamicAudio: dynamicAudioOrDefault(input),
|
||||||
proclamations: {
|
proclamations: {
|
||||||
create: proclamationIds.map((proclamationId, index) => ({
|
create: proclamationIds.map((proclamationId, index) => ({
|
||||||
proclamationId,
|
proclamationId,
|
||||||
@@ -76,36 +291,36 @@ export async function createBlock(userId: string, input: BlockInput): Promise<Bl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ok: true, id: block.id };
|
return { ok: true, id: block.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateBlock(
|
export async function updateBlock(
|
||||||
userId: string,
|
licenseKeyId: string,
|
||||||
blockId: string,
|
blockId: string,
|
||||||
input: BlockInput
|
input: BlockInput
|
||||||
): Promise<BlockMutationResult> {
|
): Promise<BlockMutationResult> {
|
||||||
const block = await prisma.block.findFirst({
|
const block = await prisma.block.findFirst({
|
||||||
where: { id: blockId, schedule: { userId } },
|
where: { id: blockId, schedule: { licenseKeyId } },
|
||||||
select: { id: true, scheduleId: true }
|
select: { id: true, scheduleId: true }
|
||||||
});
|
});
|
||||||
if (!block) return { ok: false, error: 'Block not found.' };
|
if (!block) return { ok: false, error: 'Block not found.' };
|
||||||
|
|
||||||
const siblings = await prisma.block.findMany({
|
const schedule = await prisma.schedule.findUnique({ where: { id: block.scheduleId } });
|
||||||
where: { scheduleId: block.scheduleId, id: { not: blockId } },
|
const type: ScheduleType | null =
|
||||||
select: { id: true, startMinutes: true, endMinutes: true }
|
schedule && isScheduleType(schedule.type) ? schedule.type : null;
|
||||||
});
|
const day = normaliseDay(type, input.day);
|
||||||
|
|
||||||
const validation = validateBlock(input, siblings);
|
const checked = await ensureValid(licenseKeyId, day, input, blockId);
|
||||||
if (!validation.ok) return validation;
|
if (!checked.ok) return { ok: false, error: checked.error };
|
||||||
|
|
||||||
const proclamationIds = uniqueIds(input.proclamationIds);
|
const proclamationIds = uniqueIds(input.proclamationIds);
|
||||||
await prisma.block.update({
|
await prisma.block.update({
|
||||||
where: { id: blockId },
|
where: { id: blockId },
|
||||||
data: {
|
data: {
|
||||||
|
day,
|
||||||
startMinutes: input.startMinutes,
|
startMinutes: input.startMinutes,
|
||||||
endMinutes: input.endMinutes,
|
endMinutes: input.endMinutes,
|
||||||
dynamicAudio: input.dynamicAudio === true,
|
dynamicAudio: dynamicAudioOrDefault(input),
|
||||||
proclamations: {
|
proclamations: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
create: proclamationIds.map((proclamationId, index) => ({
|
create: proclamationIds.map((proclamationId, index) => ({
|
||||||
@@ -115,13 +330,15 @@ export async function updateBlock(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { ok: true, id: blockId };
|
return { ok: true, id: blockId };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteBlock(userId: string, blockId: string): Promise<BlockMutationResult> {
|
export async function deleteBlock(
|
||||||
|
licenseKeyId: string,
|
||||||
|
blockId: string
|
||||||
|
): Promise<BlockMutationResult> {
|
||||||
const block = await prisma.block.findFirst({
|
const block = await prisma.block.findFirst({
|
||||||
where: { id: blockId, schedule: { userId } },
|
where: { id: blockId, schedule: { licenseKeyId } },
|
||||||
select: { id: true }
|
select: { id: true }
|
||||||
});
|
});
|
||||||
if (!block) return { ok: false, error: 'Block not found.' };
|
if (!block) return { ok: false, error: 'Block not found.' };
|
||||||
@@ -129,3 +346,167 @@ export async function deleteBlock(userId: string, blockId: string): Promise<Bloc
|
|||||||
await prisma.block.delete({ where: { id: blockId } });
|
await prisma.block.delete({ where: { id: blockId } });
|
||||||
return { ok: true, id: blockId };
|
return { ok: true, id: blockId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validate a whole schedule payload (used by tests and the endpoint). */
|
||||||
|
export function validateScheduleBlocks(
|
||||||
|
type: ScheduleType,
|
||||||
|
blocks: ValidationBlock[]
|
||||||
|
): ReturnType<typeof validateSchedule> {
|
||||||
|
return validateSchedule(type, blocks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flatten a loaded Schedule's Blocks (with Proclamations) for serialization. */
|
||||||
|
export function scheduleBlocksToInput(
|
||||||
|
blocks: {
|
||||||
|
id: string;
|
||||||
|
day: string | null;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamations: { proclamation: { id: string } }[];
|
||||||
|
}[]
|
||||||
|
) {
|
||||||
|
return blocks.map((block) => ({
|
||||||
|
id: block.id,
|
||||||
|
day: block.day,
|
||||||
|
startMinutes: block.startMinutes,
|
||||||
|
endMinutes: block.endMinutes,
|
||||||
|
dynamicAudio: block.dynamicAudio,
|
||||||
|
proclamationIds: block.proclamations.map((entry) => entry.proclamation.id)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Copy a Schedule between License Keys ──────────────────────────────
|
||||||
|
|
||||||
|
export type CopyScheduleResult =
|
||||||
|
| { ok: true; copied: number; targets: { id: string; label: string }[] }
|
||||||
|
| { ok: false; error: string };
|
||||||
|
|
||||||
|
/** A License Key row as needed for ownership checks and labels. */
|
||||||
|
export type OwnedLicense = { id: string; systemName: string | null; key: string };
|
||||||
|
|
||||||
|
function licenseLabel(license: { systemName: string | null; key: string }): string {
|
||||||
|
return license.systemName ?? license.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy one License Key's Schedule to one or more other License Keys, by value,
|
||||||
|
* in a single transaction: all targets succeed or none does. Targets get the
|
||||||
|
* source's `type` and fresh Block IDs (so every target's content-derived
|
||||||
|
* `version`/ETag changes and its Kiosk refetches). `dynamicAudio` and
|
||||||
|
* Proclamation references are preserved.
|
||||||
|
*
|
||||||
|
* `ownedLicenses` must be the caller's account License Keys; the source and
|
||||||
|
* every target are checked against it. The source must have a Schedule with a
|
||||||
|
* non-null type. Each resulting Schedule is validated before commit.
|
||||||
|
*/
|
||||||
|
export async function copySchedule(
|
||||||
|
ownedLicenses: OwnedLicense[],
|
||||||
|
sourceId: string,
|
||||||
|
targetIds: string[]
|
||||||
|
): Promise<CopyScheduleResult> {
|
||||||
|
const byId = new Map(ownedLicenses.map((l) => [l.id, l]));
|
||||||
|
const source = byId.get(sourceId);
|
||||||
|
if (!source) return { ok: false, error: 'Source system not found.' };
|
||||||
|
|
||||||
|
const uniqueTargets = [...new Set(targetIds)].filter((id) => id.length > 0);
|
||||||
|
if (uniqueTargets.length === 0) {
|
||||||
|
return { ok: false, error: 'Pick at least one system to copy to.' };
|
||||||
|
}
|
||||||
|
if (uniqueTargets.some((id) => id === sourceId)) {
|
||||||
|
return { ok: false, error: "A schedule can't be copied onto its own system." };
|
||||||
|
}
|
||||||
|
for (const id of uniqueTargets) {
|
||||||
|
if (!byId.has(id)) return { ok: false, error: 'Target system not found.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceSchedule = await prisma.schedule.findUnique({
|
||||||
|
where: { licenseKeyId: sourceId },
|
||||||
|
include: { blocks: { include: blockInclude } }
|
||||||
|
});
|
||||||
|
const sourceType: ScheduleType | null =
|
||||||
|
sourceSchedule && isScheduleType(sourceSchedule.type) ? sourceSchedule.type : null;
|
||||||
|
if (!sourceSchedule || sourceType === null) {
|
||||||
|
return { ok: false, error: 'The source system has no schedule to copy.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot the source content (order preserved).
|
||||||
|
const sourceBlocks = sourceSchedule.blocks.map((block) => ({
|
||||||
|
day: block.day,
|
||||||
|
startMinutes: block.startMinutes,
|
||||||
|
endMinutes: block.endMinutes,
|
||||||
|
dynamicAudio: block.dynamicAudio,
|
||||||
|
proclamationIds: block.proclamations
|
||||||
|
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
.map((entry) => entry.proclamation.id)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Validate the resulting schedule once (it is identical for every target).
|
||||||
|
const validation = validateSchedule(
|
||||||
|
sourceType,
|
||||||
|
sourceBlocks.map((b) =>
|
||||||
|
toValidationBlock({
|
||||||
|
day: b.day,
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
proclamationIds: b.proclamationIds
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!validation.ok) {
|
||||||
|
return { ok: false, error: `The source schedule is invalid: ${validation.error}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const targetId of uniqueTargets) {
|
||||||
|
// Upsert the target Schedule, replacing its type.
|
||||||
|
const existing = await tx.schedule.findUnique({ where: { licenseKeyId: targetId } });
|
||||||
|
let scheduleId: string;
|
||||||
|
if (existing) {
|
||||||
|
await tx.block.deleteMany({ where: { scheduleId: existing.id } });
|
||||||
|
await tx.schedule.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { type: sourceType }
|
||||||
|
});
|
||||||
|
scheduleId = existing.id;
|
||||||
|
} else {
|
||||||
|
const created = await tx.schedule.create({
|
||||||
|
data: { licenseKeyId: targetId, type: sourceType }
|
||||||
|
});
|
||||||
|
scheduleId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// New Block IDs per target; Proclamation refs + dynamicAudio kept.
|
||||||
|
for (const block of sourceBlocks) {
|
||||||
|
await tx.block.create({
|
||||||
|
data: {
|
||||||
|
scheduleId,
|
||||||
|
day: block.day,
|
||||||
|
startMinutes: block.startMinutes,
|
||||||
|
endMinutes: block.endMinutes,
|
||||||
|
dynamicAudio: block.dynamicAudio,
|
||||||
|
proclamations: {
|
||||||
|
create: block.proclamationIds.map((proclamationId, index) => ({
|
||||||
|
proclamationId,
|
||||||
|
sortOrder: index
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: 'Copy failed; no systems were changed.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
copied: uniqueTargets.length,
|
||||||
|
targets: uniqueTargets.map((id) => {
|
||||||
|
const license = byId.get(id) as OwnedLicense;
|
||||||
|
return { id, label: licenseLabel(license) };
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$lib/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
licenseKey: { findFirst: vi.fn(), update: vi.fn() }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { applySystemName, normalizeSystemName, SYSTEM_NAME_MAX_LENGTH } from './system-name';
|
||||||
|
|
||||||
|
const keyFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
||||||
|
const keyUpdate = prisma.licenseKey.update as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeSystemName', () => {
|
||||||
|
it('trims and returns a case-folded copy', () => {
|
||||||
|
expect(normalizeSystemName(' Living Room ')).toEqual({
|
||||||
|
ok: true,
|
||||||
|
name: 'Living Room',
|
||||||
|
lower: 'living room'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the length bounds', () => {
|
||||||
|
expect(normalizeSystemName('a'.repeat(SYSTEM_NAME_MAX_LENGTH)).ok).toBe(true);
|
||||||
|
expect(normalizeSystemName('a')).toEqual({ ok: true, name: 'a', lower: 'a' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty, whitespace, and out-of-range values', () => {
|
||||||
|
expect(normalizeSystemName('')).toEqual({ ok: false });
|
||||||
|
expect(normalizeSystemName(' ')).toEqual({ ok: false });
|
||||||
|
expect(normalizeSystemName('a'.repeat(SYSTEM_NAME_MAX_LENGTH + 1))).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-strings', () => {
|
||||||
|
expect(normalizeSystemName(undefined)).toEqual({ ok: false });
|
||||||
|
expect(normalizeSystemName(null)).toEqual({ ok: false });
|
||||||
|
expect(normalizeSystemName(42)).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applySystemName', () => {
|
||||||
|
it('reports unchanged without writing when identical', async () => {
|
||||||
|
const status = await applySystemName(
|
||||||
|
{ id: 'k1', userId: 'u1', systemName: 'Living Room' },
|
||||||
|
'Living Room',
|
||||||
|
'living room'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status).toBe('unchanged');
|
||||||
|
expect(keyFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(keyUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports conflict when another license on the account holds the name', async () => {
|
||||||
|
keyFindFirst.mockResolvedValue({ id: 'other' });
|
||||||
|
|
||||||
|
const status = await applySystemName(
|
||||||
|
{ id: 'k1', userId: 'u1', systemName: null },
|
||||||
|
'Den',
|
||||||
|
'den'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status).toBe('conflict');
|
||||||
|
expect(keyUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes the name and its case-folded copy when free', async () => {
|
||||||
|
keyFindFirst.mockResolvedValue(null);
|
||||||
|
keyUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
|
const status = await applySystemName(
|
||||||
|
{ id: 'k1', userId: 'u1', systemName: null },
|
||||||
|
'Den',
|
||||||
|
'den'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status).toBe('ok');
|
||||||
|
expect(keyUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'k1' },
|
||||||
|
data: { systemName: 'Den', systemNameLower: 'den' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the uniqueness check for a license with no account', async () => {
|
||||||
|
keyUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
|
const status = await applySystemName(
|
||||||
|
{ id: 'k1', userId: null, systemName: null },
|
||||||
|
'Den',
|
||||||
|
'den'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status).toBe('ok');
|
||||||
|
expect(keyFindFirst).not.toHaveBeenCalled();
|
||||||
|
expect(keyUpdate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps a unique-constraint violation to conflict', async () => {
|
||||||
|
keyFindFirst.mockResolvedValue(null);
|
||||||
|
keyUpdate.mockRejectedValue({ code: 'P2002' });
|
||||||
|
|
||||||
|
const status = await applySystemName(
|
||||||
|
{ id: 'k1', userId: 'u1', systemName: null },
|
||||||
|
'Den',
|
||||||
|
'den'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status).toBe('conflict');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// System Name helpers. A System Name is a human-readable label for a License
|
||||||
|
// Key, set only by the Kiosk and displayed read-only by the Portal. It is never
|
||||||
|
// a reference or foreign key. Uniqueness is per account (the owning User) and
|
||||||
|
// case-insensitive when non-null, enforced by the unique index on
|
||||||
|
// (userId, systemNameLower).
|
||||||
|
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
|
||||||
|
export const SYSTEM_NAME_MAX_LENGTH = 40;
|
||||||
|
|
||||||
|
export type SystemNameStatus = 'ok' | 'unchanged' | 'conflict' | 'invalid';
|
||||||
|
|
||||||
|
export type NormalizedName = { ok: true; name: string; lower: string } | { ok: false };
|
||||||
|
|
||||||
|
/** Trim and validate a caller-supplied System Name. Stored as typed (trimmed);
|
||||||
|
* `lower` is the case-folded copy used for uniqueness. */
|
||||||
|
export function normalizeSystemName(raw: unknown): NormalizedName {
|
||||||
|
if (typeof raw !== 'string') return { ok: false };
|
||||||
|
const name = raw.trim();
|
||||||
|
if (name.length < 1 || name.length > SYSTEM_NAME_MAX_LENGTH) return { ok: false };
|
||||||
|
return { ok: true, name, lower: name.toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemNameLicense = {
|
||||||
|
id: string;
|
||||||
|
userId: string | null;
|
||||||
|
systemName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' && error !== null && (error as { code?: string }).code === 'P2002'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a normalized name to a license. Returns the resulting status:
|
||||||
|
* - `unchanged` when the stored name is already identical
|
||||||
|
* - `conflict` when another license on the same account holds the name
|
||||||
|
* - `ok` when the name was written
|
||||||
|
* A license with no owning account (legacy, userId null) is written without a
|
||||||
|
* uniqueness check; per-account uniqueness cannot apply without an account.
|
||||||
|
*/
|
||||||
|
export async function applySystemName(
|
||||||
|
license: SystemNameLicense,
|
||||||
|
name: string,
|
||||||
|
lower: string
|
||||||
|
): Promise<SystemNameStatus> {
|
||||||
|
if (license.systemName === name) return 'unchanged';
|
||||||
|
|
||||||
|
if (license.userId) {
|
||||||
|
const conflict = await prisma.licenseKey.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: license.userId,
|
||||||
|
systemNameLower: lower,
|
||||||
|
id: { not: license.id }
|
||||||
|
},
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
if (conflict) return 'conflict';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.licenseKey.update({
|
||||||
|
where: { id: license.id },
|
||||||
|
data: { systemName: name, systemNameLower: lower }
|
||||||
|
});
|
||||||
|
return 'ok';
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) return 'conflict';
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { buildSchedulePayload, type ScheduleBlockInput } from './v2-serializers';
|
||||||
|
|
||||||
|
const FIXTURES = resolve(process.cwd(), 'docs/fixtures');
|
||||||
|
|
||||||
|
function load(name: string): {
|
||||||
|
type: 'daily' | 'weekly' | null;
|
||||||
|
daily: {
|
||||||
|
id: string;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamationIds: string[];
|
||||||
|
}[];
|
||||||
|
blocks: {
|
||||||
|
id: string;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamationIds: string[];
|
||||||
|
}[];
|
||||||
|
weekly: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamationIds: string[];
|
||||||
|
}[]
|
||||||
|
>;
|
||||||
|
} {
|
||||||
|
return JSON.parse(readFileSync(resolve(FIXTURES, name), 'utf8'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a fixture payload back into DB-shaped blocks. */
|
||||||
|
function fixtureBlocks(name: string): {
|
||||||
|
type: 'daily' | 'weekly' | null;
|
||||||
|
blocks: ScheduleBlockInput[];
|
||||||
|
} {
|
||||||
|
const fixture = load(name);
|
||||||
|
const blocks: ScheduleBlockInput[] = [];
|
||||||
|
if (fixture.type === 'daily') {
|
||||||
|
for (const b of fixture.daily) {
|
||||||
|
blocks.push({
|
||||||
|
id: b.id,
|
||||||
|
day: null,
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
dynamicAudio: b.dynamicAudio,
|
||||||
|
proclamationIds: b.proclamationIds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (fixture.type === 'weekly') {
|
||||||
|
for (const [day, list] of Object.entries(fixture.weekly)) {
|
||||||
|
for (const b of list) {
|
||||||
|
blocks.push({
|
||||||
|
id: b.id,
|
||||||
|
day,
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
dynamicAudio: b.dynamicAudio,
|
||||||
|
proclamationIds: b.proclamationIds
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { type: fixture.type, blocks };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('buildSchedulePayload against fixtures', () => {
|
||||||
|
it('round-trips the daily fixture content field-for-field', () => {
|
||||||
|
const { type, blocks } = fixtureBlocks('schedule-daily.json');
|
||||||
|
const payload = buildSchedulePayload(type, blocks);
|
||||||
|
const fixture = load('schedule-daily.json');
|
||||||
|
|
||||||
|
expect(payload.type).toBe('daily');
|
||||||
|
expect(payload.daily).toEqual(fixture.daily);
|
||||||
|
expect(payload.blocks).toEqual(fixture.blocks);
|
||||||
|
expect(payload.weekly).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('populates all seven weekly keys and leaves blocks empty for weekly', () => {
|
||||||
|
const { type, blocks } = fixtureBlocks('schedule-weekly.json');
|
||||||
|
const payload = buildSchedulePayload(type, blocks);
|
||||||
|
const fixture = load('schedule-weekly.json');
|
||||||
|
|
||||||
|
expect(payload.type).toBe('weekly');
|
||||||
|
expect(Object.keys(payload.weekly).sort()).toEqual([
|
||||||
|
'fri',
|
||||||
|
'mon',
|
||||||
|
'sat',
|
||||||
|
'sun',
|
||||||
|
'thu',
|
||||||
|
'tue',
|
||||||
|
'wed'
|
||||||
|
]);
|
||||||
|
expect(payload.weekly.sun).toEqual(fixture.weekly.sun);
|
||||||
|
expect(payload.weekly.tue).toEqual([]);
|
||||||
|
expect(payload.blocks).toEqual([]);
|
||||||
|
expect(payload.daily).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces type null with empty lists for the none fixture', () => {
|
||||||
|
const payload = buildSchedulePayload(null, []);
|
||||||
|
|
||||||
|
expect(payload.type).toBeNull();
|
||||||
|
expect(payload.daily).toEqual([]);
|
||||||
|
expect(payload.weekly).toEqual({});
|
||||||
|
expect(payload.blocks).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves midnight-crossing and adjacency in the daily flattening', () => {
|
||||||
|
const pick = (b: {
|
||||||
|
id: string;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamationIds: string[];
|
||||||
|
}) => ({
|
||||||
|
id: b.id,
|
||||||
|
startMinutes: b.startMinutes,
|
||||||
|
endMinutes: b.endMinutes,
|
||||||
|
dynamicAudio: b.dynamicAudio,
|
||||||
|
proclamationIds: b.proclamationIds
|
||||||
|
});
|
||||||
|
for (const name of ['schedule-midnight-crossing.json', 'schedule-adjacent.json']) {
|
||||||
|
const { type, blocks } = fixtureBlocks(name);
|
||||||
|
const payload = buildSchedulePayload(type, blocks);
|
||||||
|
const fixture = load(name);
|
||||||
|
// The fixture lists blocks in file order; the serializer sorts by start
|
||||||
|
// time. Compare as an order-independent set of the shared fields.
|
||||||
|
const byId = (list: ReturnType<typeof pick>[]) =>
|
||||||
|
[...list].sort((a, b) => a.id.localeCompare(b.id)).map(pick);
|
||||||
|
expect(byId(payload.blocks)).toEqual(byId(fixture.blocks));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the dynamicAudio flag per block', () => {
|
||||||
|
const { type, blocks } = fixtureBlocks('schedule-dynamic-audio.json');
|
||||||
|
const payload = buildSchedulePayload(type, blocks);
|
||||||
|
expect(payload.blocks.map((b) => b.dynamicAudio)).toEqual([true, false]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('version (ETag)', () => {
|
||||||
|
it('is stable for identical content', () => {
|
||||||
|
const { type, blocks } = fixtureBlocks('schedule-daily.json');
|
||||||
|
expect(buildSchedulePayload(type, blocks).version).toBe(
|
||||||
|
buildSchedulePayload(type, blocks).version
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes when the schedule changes', () => {
|
||||||
|
const { type, blocks } = fixtureBlocks('schedule-daily.json');
|
||||||
|
const a = buildSchedulePayload(type, blocks).version;
|
||||||
|
const changed = blocks.map((b, i) => (i === 0 ? { ...b, endMinutes: b.endMinutes + 5 } : b));
|
||||||
|
const b = buildSchedulePayload(type, changed).version;
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes with the type', () => {
|
||||||
|
const daily = buildSchedulePayload('daily', []).version;
|
||||||
|
const weekly = buildSchedulePayload('weekly', []).version;
|
||||||
|
expect(daily).not.toBe(weekly);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -139,14 +139,16 @@ describe('serializeGroup', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('serializeLicense', () => {
|
describe('serializeLicense', () => {
|
||||||
it('returns only key and email_address (levels removed)', () => {
|
it('returns key, email_address, and the advisory systemNameStatus', () => {
|
||||||
const result = serializeLicense({
|
const result = serializeLicense({
|
||||||
key: 'XXXX-XXXX-XXXX-XXXX',
|
key: 'XXXX-XXXX-XXXX-XXXX',
|
||||||
emailAddress: 'user@example.com'
|
emailAddress: 'user@example.com',
|
||||||
|
systemNameStatus: 'unchanged'
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
key: 'XXXX-XXXX-XXXX-XXXX',
|
key: 'XXXX-XXXX-XXXX-XXXX',
|
||||||
email_address: 'user@example.com'
|
email_address: 'user@example.com',
|
||||||
|
systemNameStatus: 'unchanged'
|
||||||
});
|
});
|
||||||
expect(Object.keys(result)).not.toContain('subscription_level');
|
expect(Object.keys(result)).not.toContain('subscription_level');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils';
|
import { blockDuration, isOvernight, toHm } from '$lib/schedule-utils';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
// 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
|
||||||
@@ -119,13 +120,20 @@ export function serializeGroup(g: { id: number; name: string; sortOrder: number
|
|||||||
export type V2License = {
|
export type V2License = {
|
||||||
key: string;
|
key: string;
|
||||||
email_address: string;
|
email_address: string;
|
||||||
|
systemNameStatus: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Build the POST /license response. */
|
/** Build the POST /license response. `systemNameStatus` is advisory: the Kiosk
|
||||||
export function serializeLicense(opts: { key: string; emailAddress: string }): V2License {
|
* treats "ok"/"unchanged" as synced and reverts on "conflict"/"invalid". */
|
||||||
|
export function serializeLicense(opts: {
|
||||||
|
key: string;
|
||||||
|
emailAddress: string;
|
||||||
|
systemNameStatus: string;
|
||||||
|
}): V2License {
|
||||||
return {
|
return {
|
||||||
key: opts.key,
|
key: opts.key,
|
||||||
email_address: opts.emailAddress
|
email_address: opts.emailAddress,
|
||||||
|
systemNameStatus: opts.systemNameStatus
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,3 +191,67 @@ export function serializeScheduleBlock(
|
|||||||
proclamationIds
|
proclamationIds
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A Block as read from the database, flattened for serialization. */
|
||||||
|
export type ScheduleBlockInput = {
|
||||||
|
id: string;
|
||||||
|
day: string | null;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio?: boolean;
|
||||||
|
proclamationIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type V2ScheduleType = 'daily' | 'weekly' | null;
|
||||||
|
|
||||||
|
export type V2SchedulePayload = {
|
||||||
|
version: string;
|
||||||
|
type: V2ScheduleType;
|
||||||
|
daily: V2ScheduleBlock[];
|
||||||
|
weekly: Record<string, V2ScheduleBlock[]>;
|
||||||
|
blocks: V2ScheduleBlock[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const WEEK_KEYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||||
|
|
||||||
|
function serialise(block: ScheduleBlockInput): V2ScheduleBlock {
|
||||||
|
return serializeScheduleBlock(block, block.proclamationIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the target schedule payload. `version` is an opaque content hash that
|
||||||
|
* doubles as the ETag; it is stable for equal content and changes on any
|
||||||
|
* mutation. Only the active type's lists are populated. `blocks` is the legacy
|
||||||
|
* daily flattening (empty for weekly) kept for older Kiosks.
|
||||||
|
*/
|
||||||
|
export function buildSchedulePayload(
|
||||||
|
type: V2ScheduleType,
|
||||||
|
blocks: ScheduleBlockInput[]
|
||||||
|
): V2SchedulePayload {
|
||||||
|
const dailyBlocks: V2ScheduleBlock[] = [];
|
||||||
|
const weekly: Record<string, V2ScheduleBlock[]> = {};
|
||||||
|
|
||||||
|
if (type === 'daily') {
|
||||||
|
const ordered = [...blocks].sort((a, b) => a.startMinutes - b.startMinutes);
|
||||||
|
for (const block of ordered) dailyBlocks.push(serialise(block));
|
||||||
|
} else if (type === 'weekly') {
|
||||||
|
for (const key of WEEK_KEYS) weekly[key] = [];
|
||||||
|
const ordered = [...blocks].sort((a, b) => a.startMinutes - b.startMinutes);
|
||||||
|
for (const block of ordered) {
|
||||||
|
const key = block.day ?? '';
|
||||||
|
if (key in weekly) weekly[key].push(serialise(block));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = createHash('sha1')
|
||||||
|
.update(JSON.stringify({ type, daily: dailyBlocks, weekly }))
|
||||||
|
.digest('hex');
|
||||||
|
|
||||||
|
return {
|
||||||
|
version,
|
||||||
|
type,
|
||||||
|
daily: dailyBlocks,
|
||||||
|
weekly,
|
||||||
|
blocks: type === 'daily' ? dailyBlocks : []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
class="border-b border-slate-200 bg-slate-50 text-xs tracking-wide text-slate-500 uppercase dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400"
|
class="border-b border-slate-200 bg-slate-50 text-xs tracking-wide text-slate-500 uppercase dark:border-slate-800 dark:bg-slate-950 dark:text-slate-400"
|
||||||
>
|
>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="px-6 py-3 font-semibold">System Name</th>
|
||||||
<th class="px-6 py-3 font-semibold">Key</th>
|
<th class="px-6 py-3 font-semibold">Key</th>
|
||||||
<th class="px-6 py-3 font-semibold">Status</th>
|
<th class="px-6 py-3 font-semibold">Status</th>
|
||||||
<th class="px-6 py-3 font-semibold">Created</th>
|
<th class="px-6 py-3 font-semibold">Created</th>
|
||||||
@@ -53,6 +54,13 @@
|
|||||||
<tbody class="divide-y divide-slate-100">
|
<tbody class="divide-y divide-slate-100">
|
||||||
{#each data.licenseKeys as key (key.key)}
|
{#each data.licenseKeys as key (key.key)}
|
||||||
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800">
|
<tr class="hover:bg-slate-50 dark:hover:bg-slate-800">
|
||||||
|
<td class="px-6 py-4 text-slate-800 dark:text-slate-200">
|
||||||
|
{#if key.systemName}
|
||||||
|
{key.systemName}
|
||||||
|
{:else}
|
||||||
|
<span class="text-slate-400 dark:text-slate-500">Unnamed system</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
<td class="px-6 py-4 font-mono text-slate-800 dark:text-slate-200">{key.key}</td>
|
<td class="px-6 py-4 font-mono text-slate-800 dark:text-slate-200">{key.key}</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{#if key.active}
|
{#if key.active}
|
||||||
@@ -104,6 +112,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span class="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
{key.systemName ?? 'Unnamed system'}
|
||||||
|
</span>
|
||||||
<span class="text-xs text-slate-500 dark:text-slate-400"
|
<span class="text-xs text-slate-500 dark:text-slate-400"
|
||||||
>Created {formatDate(key.createdAt)}</span
|
>Created {formatDate(key.createdAt)}</span
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import { clientFormatPhoneNumber } from '$lib/utils';
|
import { clientFormatPhoneNumber } from '$lib/utils';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST" action="?/updateProfile" class="mt-5 flex flex-col gap-4">
|
<form method="POST" action="?/updateProfile" use:enhance class="mt-5 flex flex-col gap-4">
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<div class="flex flex-col gap-1.5">
|
<div class="flex flex-col gap-1.5">
|
||||||
<label for="firstName" class="text-sm font-medium text-slate-700 dark:text-slate-300">
|
<label for="firstName" class="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
@@ -206,6 +207,7 @@
|
|||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action={editingAddressId ? '?/updateAddress' : '?/addAddress'}
|
action={editingAddressId ? '?/updateAddress' : '?/addAddress'}
|
||||||
|
use:enhance
|
||||||
class="mt-5 flex flex-col gap-4"
|
class="mt-5 flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
{#if editingAddressId}
|
{#if editingAddressId}
|
||||||
@@ -314,8 +316,10 @@
|
|||||||
<form
|
<form
|
||||||
method="POST"
|
method="POST"
|
||||||
action="?/deleteAddress"
|
action="?/deleteAddress"
|
||||||
onsubmit={(event) => {
|
use:enhance={({ cancel }) => {
|
||||||
if (!confirm('Delete this address?')) event.preventDefault();
|
if (!confirm('Delete this address?')) {
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<input type="hidden" name="id" value={address.id} />
|
<input type="hidden" name="id" value={address.id} />
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import Window from '$lib/components/containers/Window.svelte';
|
import Window from '$lib/components/containers/Window.svelte';
|
||||||
import { clientFormatPhoneNumber } from '$lib/utils';
|
import { clientFormatPhoneNumber } from '$lib/utils';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
@@ -42,7 +43,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Window>
|
<Window>
|
||||||
<form method="POST" class="flex flex-col items-center gap-5">
|
<form method="POST" use:enhance class="flex flex-col items-center gap-5">
|
||||||
<h1 class="text-center text-2xl font-bold text-slate-900 dark:text-slate-100">
|
<h1 class="text-center text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
Please finish setting up your profile
|
Please finish setting up your profile
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
import { error, fail } from '@sveltejs/kit';
|
import { error, fail } from '@sveltejs/kit';
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
import { createBlock, deleteBlock, listBlocks, updateBlock } from '$lib/server/schedule';
|
import {
|
||||||
|
copySchedule,
|
||||||
|
createBlock,
|
||||||
|
deleteBlock,
|
||||||
|
getScheduleForLicense,
|
||||||
|
setScheduleType,
|
||||||
|
updateBlock
|
||||||
|
} from '$lib/server/schedule';
|
||||||
|
|
||||||
|
const WEEK_DAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||||
|
|
||||||
function parseInput(formData: FormData) {
|
function parseInput(formData: FormData) {
|
||||||
const startMinutes = Number(formData.get('startMinutes'));
|
const startMinutes = Number(formData.get('startMinutes'));
|
||||||
const endMinutes = Number(formData.get('endMinutes'));
|
const endMinutes = Number(formData.get('endMinutes'));
|
||||||
|
const dayRaw = formData.get('day')?.toString() ?? '';
|
||||||
|
const day = WEEK_DAYS.includes(dayRaw) ? dayRaw : null;
|
||||||
let proclamationIds: string[] = [];
|
let proclamationIds: string[] = [];
|
||||||
const raw = formData.get('proclamationIds');
|
const raw = formData.get('proclamationIds');
|
||||||
if (typeof raw === 'string' && raw.length > 0) {
|
if (typeof raw === 'string' && raw.length > 0) {
|
||||||
@@ -19,16 +30,29 @@ function parseInput(formData: FormData) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const dynamicAudio = formData.get('dynamicAudio') === 'true';
|
const dynamicAudio = formData.get('dynamicAudio') === 'true';
|
||||||
return { startMinutes, endMinutes, proclamationIds, dynamicAudio };
|
return { day, startMinutes, endMinutes, proclamationIds, dynamicAudio };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
/** Ensure the given License Key belongs to the signed-in account. */
|
||||||
|
async function ownedLicense(userId: string, licenseKeyId: string) {
|
||||||
|
const license = await prisma.licenseKey.findFirst({
|
||||||
|
where: { id: licenseKeyId, userId },
|
||||||
|
select: { id: true, key: true, systemName: true }
|
||||||
|
});
|
||||||
|
return license;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
if (!locals.user?.id) {
|
if (!locals.user?.id) {
|
||||||
throw error(403, 'not authorized');
|
throw error(403, 'not authorized');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [blocks, categories, groups, proclamations] = await Promise.all([
|
const [licenses, categories, groups, proclamations] = await Promise.all([
|
||||||
listBlocks(locals.user.id),
|
prisma.licenseKey.findMany({
|
||||||
|
where: { userId: locals.user.id },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
select: { id: true, key: true, systemName: true, active: true }
|
||||||
|
}),
|
||||||
prisma.proclamationCategory.findMany({
|
prisma.proclamationCategory.findMany({
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
|
orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }]
|
||||||
@@ -45,27 +69,90 @@ export const load: PageServerLoad = async ({ locals }) => {
|
|||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const blockViews = blocks.map((block) => ({
|
// Which License Keys already have a (typed) Schedule — used by the Copy UI
|
||||||
id: block.id,
|
// to warn before overwriting a target.
|
||||||
startMinutes: block.startMinutes,
|
const typedSchedules = await prisma.schedule.findMany({
|
||||||
endMinutes: block.endMinutes,
|
where: { licenseKeyId: { in: licenses.map((l) => l.id) }, type: { not: null } },
|
||||||
dynamicAudio: block.dynamicAudio,
|
select: { licenseKeyId: true }
|
||||||
proclamations: block.proclamations.map((entry) => ({
|
});
|
||||||
id: entry.proclamation.id,
|
const scheduledIds = new Set(typedSchedules.map((s) => s.licenseKeyId));
|
||||||
name: entry.proclamation.name,
|
const licenseViews = licenses.map((l) => ({
|
||||||
categoryId: entry.proclamation.categoryId,
|
id: l.id,
|
||||||
groupId: entry.proclamation.groupId
|
key: l.key,
|
||||||
}))
|
systemName: l.systemName,
|
||||||
|
active: l.active,
|
||||||
|
hasSchedule: scheduledIds.has(l.id)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { blocks: blockViews, categories, groups, proclamations };
|
const requested = url.searchParams.get('license');
|
||||||
|
const selected = licenseViews.find((l) => l.id === requested) ?? licenseViews[0] ?? null;
|
||||||
|
|
||||||
|
let type: 'daily' | 'weekly' | null = null;
|
||||||
|
let blocks: {
|
||||||
|
id: string;
|
||||||
|
day: string | null;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[];
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
if (selected) {
|
||||||
|
const schedule = await getScheduleForLicense(selected.id);
|
||||||
|
if (schedule) {
|
||||||
|
type = schedule.type === 'daily' || schedule.type === 'weekly' ? schedule.type : null;
|
||||||
|
blocks = schedule.blocks.map((block) => ({
|
||||||
|
id: block.id,
|
||||||
|
day: block.day,
|
||||||
|
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 {
|
||||||
|
licenses: licenseViews,
|
||||||
|
selectedLicenseId: selected?.id ?? null,
|
||||||
|
type,
|
||||||
|
blocks,
|
||||||
|
categories,
|
||||||
|
groups,
|
||||||
|
proclamations
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const actions: Actions = {
|
export const actions: Actions = {
|
||||||
|
setType: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||||
|
const formData = await request.formData();
|
||||||
|
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
|
||||||
|
const rawType = formData.get('type')?.toString() ?? '';
|
||||||
|
const type = rawType === 'daily' || rawType === 'weekly' ? rawType : null;
|
||||||
|
const confirm = formData.get('confirm') === 'true';
|
||||||
|
|
||||||
|
const license = await ownedLicense(locals.user.id, licenseKeyId);
|
||||||
|
if (!license) return fail(404, { error: 'System not found.' });
|
||||||
|
|
||||||
|
const result = await setScheduleType(licenseKeyId, type, { confirmed: confirm });
|
||||||
|
if (!result.ok) return fail(400, { error: result.error });
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
createBlock: async ({ request, locals }) => {
|
createBlock: async ({ request, locals }) => {
|
||||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||||
const input = parseInput(await request.formData());
|
const formData = await request.formData();
|
||||||
const result = await createBlock(locals.user.id, input);
|
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
|
||||||
|
const license = await ownedLicense(locals.user.id, licenseKeyId);
|
||||||
|
if (!license) return fail(404, { error: 'System not found.' });
|
||||||
|
|
||||||
|
const result = await createBlock(licenseKeyId, parseInput(formData));
|
||||||
if (!result.ok) return fail(400, { error: result.error });
|
if (!result.ok) return fail(400, { error: result.error });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
@@ -73,19 +160,57 @@ export const actions: Actions = {
|
|||||||
updateBlock: async ({ request, locals }) => {
|
updateBlock: async ({ request, locals }) => {
|
||||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
|
||||||
const id = formData.get('id')?.toString();
|
const id = formData.get('id')?.toString();
|
||||||
|
const license = await ownedLicense(locals.user.id, licenseKeyId);
|
||||||
|
if (!license) return fail(404, { error: 'System not found.' });
|
||||||
if (!id) return fail(400, { error: 'Block not found.' });
|
if (!id) return fail(400, { error: 'Block not found.' });
|
||||||
const result = await updateBlock(locals.user.id, id, parseInput(formData));
|
|
||||||
|
const result = await updateBlock(licenseKeyId, id, parseInput(formData));
|
||||||
if (!result.ok) return fail(400, { error: result.error });
|
if (!result.ok) return fail(400, { error: result.error });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteBlock: async ({ request, locals }) => {
|
deleteBlock: async ({ request, locals }) => {
|
||||||
if (!locals.user?.id) throw error(403, 'not authorized');
|
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||||
const id = (await request.formData()).get('id')?.toString();
|
const formData = await request.formData();
|
||||||
|
const licenseKeyId = formData.get('licenseKeyId')?.toString() ?? '';
|
||||||
|
const id = formData.get('id')?.toString();
|
||||||
|
const license = await ownedLicense(locals.user.id, licenseKeyId);
|
||||||
|
if (!license) return fail(404, { error: 'System not found.' });
|
||||||
if (!id) return fail(400, { error: 'Block not found.' });
|
if (!id) return fail(400, { error: 'Block not found.' });
|
||||||
const result = await deleteBlock(locals.user.id, id);
|
|
||||||
|
const result = await deleteBlock(licenseKeyId, id);
|
||||||
if (!result.ok) return fail(400, { error: result.error });
|
if (!result.ok) return fail(400, { error: result.error });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
copy: async ({ request, locals }) => {
|
||||||
|
if (!locals.user?.id) throw error(403, 'not authorized');
|
||||||
|
const formData = await request.formData();
|
||||||
|
const sourceId = formData.get('sourceLicenseKeyId')?.toString() ?? '';
|
||||||
|
const targetIds = formData
|
||||||
|
.getAll('targetIds')
|
||||||
|
.map((v) => v.toString())
|
||||||
|
.filter((id) => id.length > 0);
|
||||||
|
|
||||||
|
// Only the caller's own systems are eligible; ownership is enforced by
|
||||||
|
// scoping the lookup to the session user.
|
||||||
|
const owned = await prisma.licenseKey.findMany({
|
||||||
|
where: { userId: locals.user.id },
|
||||||
|
select: { id: true, systemName: true, key: true }
|
||||||
|
});
|
||||||
|
if (!owned.some((l) => l.id === sourceId)) {
|
||||||
|
return fail(404, { error: 'System not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await copySchedule(owned, sourceId, targetIds);
|
||||||
|
if (!result.ok) return fail(400, { error: result.error });
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
action: 'copy',
|
||||||
|
copied: result.copied,
|
||||||
|
targets: result.targets
|
||||||
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,32 +1,138 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import type { SubmitFunction } from '@sveltejs/kit';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import DaySchedule from '$lib/components/schedule/DaySchedule.svelte';
|
import WeekSchedule from '$lib/components/schedule/WeekSchedule.svelte';
|
||||||
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
|
import BlockModal from '$lib/components/schedule/BlockModal.svelte';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
|
const WEEK_DAYS: { key: string; label: string; short: string }[] = [
|
||||||
|
{ key: 'mon', label: 'Monday', short: 'Mon' },
|
||||||
|
{ key: 'tue', label: 'Tuesday', short: 'Tue' },
|
||||||
|
{ key: 'wed', label: 'Wednesday', short: 'Wed' },
|
||||||
|
{ key: 'thu', label: 'Thursday', short: 'Thu' },
|
||||||
|
{ key: 'fri', label: 'Friday', short: 'Fri' },
|
||||||
|
{ key: 'sat', label: 'Saturday', short: 'Sat' },
|
||||||
|
{ key: 'sun', label: 'Sunday', short: 'Sun' }
|
||||||
|
];
|
||||||
|
|
||||||
|
type BlockView = {
|
||||||
|
id: string;
|
||||||
|
day: string | null;
|
||||||
|
startMinutes: number;
|
||||||
|
endMinutes: number;
|
||||||
|
dynamicAudio: boolean;
|
||||||
|
proclamations: { id: string; name: string; categoryId: number; groupId: number | null }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render straight from the server data. Every action invalidates and reloads
|
||||||
|
// it, so the view stays in sync without a local mirror that can go stale.
|
||||||
|
const type = $derived(data.type);
|
||||||
|
const blocks: BlockView[] = $derived(data.blocks);
|
||||||
|
let typePending = $state<'daily' | 'weekly' | null>(null);
|
||||||
|
let showTypeConfirm = $state(false);
|
||||||
|
let successMessage = $state('');
|
||||||
|
let errorMessage = $state('');
|
||||||
|
|
||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
let modalMode = $state<'create' | 'edit'>('create');
|
let modalMode = $state<'create' | 'edit'>('create');
|
||||||
|
let modalDay = $state<string | null>(null);
|
||||||
let editingId = $state<string | undefined>(undefined);
|
let editingId = $state<string | undefined>(undefined);
|
||||||
let initialStart = $state(9 * 60);
|
let initialStart = $state(9 * 60);
|
||||||
let initialEnd = $state(10 * 60);
|
let initialEnd = $state(10 * 60);
|
||||||
let initialIds = $state<string[]>([]);
|
let initialIds = $state<string[]>([]);
|
||||||
let initialDynamicAudio = $state(false);
|
let initialDynamicAudio = $state(true);
|
||||||
let successMessage = $state('');
|
|
||||||
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
let closeTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
function openCreate(startMinutes: number, endMinutes: number) {
|
// Hidden forms submit the ?/setType and ?/copy actions with use:enhance so the
|
||||||
|
// result comes back as a proper ActionResult (no full-page reload).
|
||||||
|
let typeForm: HTMLFormElement | undefined = $state();
|
||||||
|
let copyForm: HTMLFormElement | undefined = $state();
|
||||||
|
|
||||||
|
// Copy-to dialog state.
|
||||||
|
let copyOpen = $state(false);
|
||||||
|
let copyTargets = $state<string[]>([]);
|
||||||
|
let copyError = $state('');
|
||||||
|
|
||||||
|
const copyCandidates = $derived(data.licenses.filter((l) => l.id !== data.selectedLicenseId));
|
||||||
|
const overwritingTargets = $derived(
|
||||||
|
copyCandidates.filter((l) => copyTargets.includes(l.id) && l.hasSchedule)
|
||||||
|
);
|
||||||
|
const needsCopyConfirm = $derived(overwritingTargets.length > 0);
|
||||||
|
|
||||||
|
const selectedLicense = $derived(
|
||||||
|
data.licenses.find((l) => l.id === data.selectedLicenseId) ?? null
|
||||||
|
);
|
||||||
|
const licenseLabel = $derived(
|
||||||
|
selectedLicense ? (selectedLicense.systemName ?? selectedLicense.key) : 'No systems'
|
||||||
|
);
|
||||||
|
const dailyBlocks = $derived(blocks.filter((b) => b.day === null));
|
||||||
|
const hasBlocks = $derived(blocks.length > 0);
|
||||||
|
|
||||||
|
function flash(message: string) {
|
||||||
|
successMessage = message;
|
||||||
|
clearTimeout(closeTimer);
|
||||||
|
closeTimer = setTimeout(() => (successMessage = ''), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSystem(licenseKeyId: string) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (licenseKeyId) url.searchParams.set('license', licenseKeyId);
|
||||||
|
else url.searchParams.delete('license');
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestType(next: 'daily' | 'weekly' | null) {
|
||||||
|
if (next === type) return;
|
||||||
|
if (hasBlocks && type !== null && next !== null) {
|
||||||
|
typePending = next;
|
||||||
|
showTypeConfirm = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyType(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Submit the hidden ?/setType form. The enhance callback reloads the server
|
||||||
|
// data (prefilling/clearing Blocks as needed) without a full navigation.
|
||||||
|
function applyType(next: 'daily' | 'weekly' | null) {
|
||||||
|
showTypeConfirm = false;
|
||||||
|
errorMessage = '';
|
||||||
|
const form = typeForm;
|
||||||
|
if (!form) return;
|
||||||
|
(form.querySelector('input[name="type"]') as HTMLInputElement).value = next ?? '';
|
||||||
|
(form.querySelector('input[name="confirm"]') as HTMLInputElement).value = 'true';
|
||||||
|
form.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTypeSubmit: SubmitFunction = () => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure') {
|
||||||
|
errorMessage =
|
||||||
|
(result.data as { error?: string })?.error ?? 'Could not change the schedule type.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// update() applies the action result and reloads all data (it calls
|
||||||
|
// invalidateAll by default), pulling the new type, blocks and flags.
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function openCreate(day: string | null, startMinutes: number, endMinutes: number) {
|
||||||
modalMode = 'create';
|
modalMode = 'create';
|
||||||
editingId = undefined;
|
editingId = undefined;
|
||||||
|
modalDay = day;
|
||||||
initialStart = startMinutes;
|
initialStart = startMinutes;
|
||||||
initialEnd = endMinutes;
|
initialEnd = endMinutes;
|
||||||
initialIds = [];
|
initialIds = [];
|
||||||
initialDynamicAudio = false;
|
initialDynamicAudio = true;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(block: {
|
function openEdit(block: {
|
||||||
id: string;
|
id: string;
|
||||||
|
day: string | null;
|
||||||
startMinutes: number;
|
startMinutes: number;
|
||||||
endMinutes: number;
|
endMinutes: number;
|
||||||
dynamicAudio: boolean;
|
dynamicAudio: boolean;
|
||||||
@@ -34,9 +140,10 @@
|
|||||||
}) {
|
}) {
|
||||||
modalMode = 'edit';
|
modalMode = 'edit';
|
||||||
editingId = block.id;
|
editingId = block.id;
|
||||||
|
modalDay = block.day;
|
||||||
initialStart = block.startMinutes;
|
initialStart = block.startMinutes;
|
||||||
initialEnd = block.endMinutes;
|
initialEnd = block.endMinutes;
|
||||||
initialIds = block.proclamations.map((proclamation) => proclamation.id);
|
initialIds = block.proclamations.map((p) => p.id);
|
||||||
initialDynamicAudio = block.dynamicAudio;
|
initialDynamicAudio = block.dynamicAudio;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
@@ -47,12 +154,62 @@
|
|||||||
|
|
||||||
function handleSaved() {
|
function handleSaved() {
|
||||||
modalOpen = false;
|
modalOpen = false;
|
||||||
successMessage = modalMode === 'edit' ? 'Block saved.' : 'Block created.';
|
flash(modalMode === 'edit' ? 'Block saved.' : 'Block created.');
|
||||||
clearTimeout(closeTimer);
|
|
||||||
closeTimer = setTimeout(() => (successMessage = ''), 1500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCopy() {
|
||||||
|
copyTargets = [];
|
||||||
|
copyError = '';
|
||||||
|
copyOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCopyTarget(id: string, checked: boolean) {
|
||||||
|
copyTargets = checked
|
||||||
|
? [...new Set([...copyTargets, id])]
|
||||||
|
: copyTargets.filter((t) => t !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitCopy() {
|
||||||
|
copyError = '';
|
||||||
|
if (copyTargets.length === 0) {
|
||||||
|
copyError = 'Pick at least one system to copy to.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
copyForm?.requestSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopySubmit: SubmitFunction = () => {
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'failure') {
|
||||||
|
copyError = (result.data as { error?: string })?.error ?? 'Copy failed.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.type === 'error') {
|
||||||
|
copyError = 'Copy failed.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data =
|
||||||
|
'data' in result ? (result.data as { targets?: { label: string }[] }) : undefined;
|
||||||
|
const targets = (data?.targets ?? []).map((t) => t.label);
|
||||||
|
copyOpen = false;
|
||||||
|
flash(`Copied to ${targets.join(', ')}.`);
|
||||||
|
await update();
|
||||||
|
};
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/setType"
|
||||||
|
use:enhance={handleTypeSubmit}
|
||||||
|
bind:this={typeForm}
|
||||||
|
class="hidden"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="licenseKeyId" value={data.selectedLicenseId ?? ''} />
|
||||||
|
<input type="hidden" name="type" value="" />
|
||||||
|
<input type="hidden" name="confirm" value="false" />
|
||||||
|
</form>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -60,14 +217,23 @@
|
|||||||
Schedule
|
Schedule
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-1 text-sm text-slate-500 sm:text-base dark:text-slate-400">
|
<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.
|
Build a listening schedule for a system. Up to 4 blocks per day.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span
|
{#if data.licenses.length > 0}
|
||||||
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"
|
<label class="flex items-center gap-2 text-sm">
|
||||||
>
|
<span class="text-slate-500 dark:text-slate-400">System</span>
|
||||||
{data.blocks.length} of 4 blocks
|
<select
|
||||||
</span>
|
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 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"
|
||||||
|
value={data.selectedLicenseId ?? ''}
|
||||||
|
onchange={(e) => selectSystem(e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
{#each data.licenses as license (license.id)}
|
||||||
|
<option value={license.id}>{license.systemName ?? license.key}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if successMessage}
|
{#if successMessage}
|
||||||
@@ -77,24 +243,273 @@
|
|||||||
{successMessage}
|
{successMessage}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if errorMessage}
|
||||||
|
<div
|
||||||
|
class="mt-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"
|
||||||
|
>
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="mt-6">
|
{#if !selectedLicense}
|
||||||
<DaySchedule blocks={data.blocks} onCreate={openCreate} onEdit={openEdit} />
|
<div
|
||||||
</div>
|
class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
You don't have any systems yet. A system appears here once a Kiosk validates its License
|
||||||
|
Key.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- System + mode -->
|
||||||
|
<div class="mt-6 flex flex-wrap items-center gap-4">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">System</span>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
{licenseLabel}
|
||||||
|
{#if !selectedLicense.systemName}
|
||||||
|
<span class="text-slate-400 dark:text-slate-500">(unnamed system)</span>
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">Schedule type</span>
|
||||||
|
<div
|
||||||
|
class="mt-1 inline-flex overflow-hidden rounded-md border border-slate-300 dark:border-slate-700"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1.5 text-sm {type === 'daily'
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-white text-slate-700 dark:bg-slate-900 dark:text-slate-300'}"
|
||||||
|
onclick={() => requestType('daily')}>Daily</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-1.5 text-sm {type === 'weekly'
|
||||||
|
? 'bg-indigo-600 text-white'
|
||||||
|
: 'bg-white text-slate-700 dark:bg-slate-900 dark:text-slate-300'}"
|
||||||
|
onclick={() => requestType('weekly')}>Weekly</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{#if type !== null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-2 text-sm text-red-600 hover:underline dark:text-red-400"
|
||||||
|
onclick={() => requestType(null)}>Delete schedule</button
|
||||||
|
>
|
||||||
|
{#if copyCandidates.length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ml-3 text-sm text-indigo-600 hover:underline dark:text-indigo-400"
|
||||||
|
onclick={openCopy}>Copy to…</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<BlockModal
|
{#if type === null}
|
||||||
open={modalOpen}
|
<div
|
||||||
mode={modalMode}
|
class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center dark:border-slate-700 dark:bg-slate-900"
|
||||||
blockId={editingId}
|
>
|
||||||
initialStartMinutes={initialStart}
|
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||||
initialEndMinutes={initialEnd}
|
No schedule. Choose Daily or Weekly to start.
|
||||||
initialProclamationIds={initialIds}
|
</p>
|
||||||
{initialDynamicAudio}
|
</div>
|
||||||
categories={data.categories}
|
<div
|
||||||
groups={data.groups}
|
class="mt-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300"
|
||||||
proclamations={data.proclamations}
|
>
|
||||||
existingBlocks={data.blocks}
|
Times run in the Kiosk's own timezone ({selectedLicense.systemName ?? selectedLicense.key}
|
||||||
onClose={closeModal}
|
sets its own timezone). They are not tied to your browser's timezone.
|
||||||
onSaved={handleSaved}
|
</div>
|
||||||
/>
|
{:else if type === 'daily'}
|
||||||
|
<div class="mt-6">
|
||||||
|
<WeekSchedule
|
||||||
|
days={[{ key: 'daily', label: 'Every day' }]}
|
||||||
|
blocks={dailyBlocks}
|
||||||
|
onCreate={(_day, s, e) => openCreate(null, s, e)}
|
||||||
|
onEdit={openEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="mt-6">
|
||||||
|
<WeekSchedule
|
||||||
|
days={WEEK_DAYS}
|
||||||
|
{blocks}
|
||||||
|
onCreate={(day, s, e) => openCreate(day, s, e)}
|
||||||
|
onEdit={openEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="mt-4 text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Gaps between blocks are fine: the Kiosk plays its locally selected Proclamations during a gap.
|
||||||
|
Times run in the Kiosk's own timezone ({selectedLicense.systemName ?? selectedLicense.key}),
|
||||||
|
not your browser's.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<BlockModal
|
||||||
|
open={modalOpen}
|
||||||
|
mode={modalMode}
|
||||||
|
blockId={editingId}
|
||||||
|
day={modalDay}
|
||||||
|
licenseKeyId={data.selectedLicenseId ?? ''}
|
||||||
|
scheduleType={type}
|
||||||
|
initialStartMinutes={initialStart}
|
||||||
|
initialEndMinutes={initialEnd}
|
||||||
|
initialProclamationIds={initialIds}
|
||||||
|
{initialDynamicAudio}
|
||||||
|
categories={data.categories}
|
||||||
|
groups={data.groups}
|
||||||
|
proclamations={data.proclamations}
|
||||||
|
existingBlocks={blocks}
|
||||||
|
onClose={closeModal}
|
||||||
|
onSaved={handleSaved}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if showTypeConfirm}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4">
|
||||||
|
<div
|
||||||
|
class="relative z-10 w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-xl dark:border-slate-800 dark:bg-slate-900"
|
||||||
|
>
|
||||||
|
<h2 class="text-lg font-bold text-slate-900 dark:text-slate-100">Change schedule type?</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
Switching type replaces the current blocks.
|
||||||
|
{#if typePending === 'weekly'}
|
||||||
|
The Daily blocks will be copied to all seven days.
|
||||||
|
{:else}
|
||||||
|
The existing blocks will be cleared.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="mt-4 flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300"
|
||||||
|
onclick={() => (showTypeConfirm = false)}>Cancel</button
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
|
||||||
|
onclick={() => applyType(typePending)}>Continue</button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if copyOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4">
|
||||||
|
<div
|
||||||
|
class="relative z-10 flex max-h-[90vh] w-full max-w-lg 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">
|
||||||
|
Copy schedule to…
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
onclick={() => (copyOpen = false)}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto px-6 py-5">
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
Copy <span class="font-medium text-slate-700 dark:text-slate-200"
|
||||||
|
>{selectedLicense ? (selectedLicense.systemName ?? selectedLicense.key) : ''}</span
|
||||||
|
>'s schedule to other systems. Copies are independent snapshots.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if copyError}
|
||||||
|
<div
|
||||||
|
class="mt-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"
|
||||||
|
>
|
||||||
|
{copyError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul class="mt-4 flex flex-col gap-2">
|
||||||
|
{#each copyCandidates as candidate (candidate.id)}
|
||||||
|
<li>
|
||||||
|
<label
|
||||||
|
class="flex items-start gap-3 rounded-md border border-slate-200 px-3 py-2 text-sm dark:border-slate-800"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-0.5 h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500 dark:border-slate-600"
|
||||||
|
checked={copyTargets.includes(candidate.id)}
|
||||||
|
onchange={(e) => toggleCopyTarget(candidate.id, e.currentTarget.checked)}
|
||||||
|
/>
|
||||||
|
<span class="flex flex-col">
|
||||||
|
<span class="font-medium text-slate-800 dark:text-slate-200">
|
||||||
|
{candidate.systemName ?? candidate.key}
|
||||||
|
</span>
|
||||||
|
{#if !candidate.systemName}
|
||||||
|
<span class="text-xs text-slate-400 dark:text-slate-500">Unnamed system</span>
|
||||||
|
{/if}
|
||||||
|
{#if candidate.hasSchedule}
|
||||||
|
<span class="text-xs text-amber-700 dark:text-amber-400">
|
||||||
|
Has a schedule — copying will overwrite it
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{#if needsCopyConfirm}
|
||||||
|
<div
|
||||||
|
class="mt-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300"
|
||||||
|
>
|
||||||
|
This will overwrite the existing schedule on
|
||||||
|
{overwritingTargets.map((t) => t.systemName ?? t.key).join(', ')}.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex shrink-0 items-center justify-end gap-3 border-t border-slate-200 px-6 py-4 dark:border-slate-800"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
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"
|
||||||
|
onclick={() => (copyOpen = false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700 disabled:opacity-50"
|
||||||
|
disabled={copyTargets.length === 0}
|
||||||
|
onclick={submitCopy}
|
||||||
|
>
|
||||||
|
{needsCopyConfirm ? 'Overwrite & copy' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form
|
||||||
|
method="POST"
|
||||||
|
action="?/copy"
|
||||||
|
use:enhance={handleCopySubmit}
|
||||||
|
bind:this={copyForm}
|
||||||
|
class="hidden"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="sourceLicenseKeyId" value={data.selectedLicenseId ?? ''} />
|
||||||
|
{#each copyTargets as target (target)}
|
||||||
|
<input type="hidden" name="targetIds" value={target} />
|
||||||
|
{/each}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -6,25 +6,40 @@ vi.mock('$lib/db', () => ({
|
|||||||
prisma: {
|
prisma: {
|
||||||
proclamationCategory: { findMany: vi.fn() },
|
proclamationCategory: { findMany: vi.fn() },
|
||||||
proclamationGroup: { findMany: vi.fn() },
|
proclamationGroup: { findMany: vi.fn() },
|
||||||
proclamation: { findMany: vi.fn() }
|
proclamation: { findMany: vi.fn() },
|
||||||
|
licenseKey: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||||
|
schedule: { findMany: vi.fn() }
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('$lib/server/schedule', () => ({
|
vi.mock('$lib/server/schedule', () => ({
|
||||||
listBlocks: vi.fn(),
|
getScheduleForLicense: vi.fn(),
|
||||||
|
setScheduleType: vi.fn(),
|
||||||
createBlock: vi.fn(),
|
createBlock: vi.fn(),
|
||||||
updateBlock: vi.fn(),
|
updateBlock: vi.fn(),
|
||||||
deleteBlock: vi.fn()
|
deleteBlock: vi.fn(),
|
||||||
|
copySchedule: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
import { createBlock, deleteBlock, listBlocks } from '$lib/server/schedule';
|
import {
|
||||||
|
copySchedule,
|
||||||
|
createBlock,
|
||||||
|
deleteBlock,
|
||||||
|
getScheduleForLicense,
|
||||||
|
setScheduleType
|
||||||
|
} from '$lib/server/schedule';
|
||||||
import { actions, load } from './+page.server';
|
import { actions, load } from './+page.server';
|
||||||
|
|
||||||
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
const procFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
||||||
const listBlocksMock = listBlocks as ReturnType<typeof vi.fn>;
|
const licenseFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
|
||||||
|
const licenseFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
||||||
|
const scheduleFindMany = prisma.schedule.findMany as ReturnType<typeof vi.fn>;
|
||||||
|
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
||||||
|
const setTypeMock = setScheduleType as ReturnType<typeof vi.fn>;
|
||||||
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
|
const createBlockMock = createBlock as ReturnType<typeof vi.fn>;
|
||||||
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
|
const deleteBlockMock = deleteBlock as ReturnType<typeof vi.fn>;
|
||||||
|
const copyScheduleMock = copySchedule as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
const locals = createLocals(mockUser() as never);
|
const locals = createLocals(mockUser() as never);
|
||||||
|
|
||||||
@@ -37,18 +52,25 @@ beforeEach(() => {
|
|||||||
{ id: 1, name: 'General' }
|
{ id: 1, name: 'General' }
|
||||||
]);
|
]);
|
||||||
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
|
procFindMany.mockResolvedValue([{ id: 'p1', name: 'Peace', categoryId: 2, groupId: 1 }]);
|
||||||
listBlocksMock.mockResolvedValue([]);
|
licenseFindMany.mockResolvedValue([
|
||||||
|
{ id: 'lk1', key: 'AAAA-BBBB-CCCC-DDDD', systemName: 'Living Room', active: true }
|
||||||
|
]);
|
||||||
|
licenseFindFirst.mockResolvedValue({ id: 'lk1' });
|
||||||
|
scheduleFindMany.mockResolvedValue([]);
|
||||||
|
getScheduleMock.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('schedule load', () => {
|
describe('schedule load', () => {
|
||||||
it('rejects unauthenticated requests', async () => {
|
it('rejects unauthenticated requests', async () => {
|
||||||
await expect(load({ locals: createLocals() } as never)).rejects.toMatchObject({
|
await expect(
|
||||||
|
load({ locals: createLocals(), url: new URL('http://x/') } as never)
|
||||||
|
).rejects.toMatchObject({
|
||||||
status: 403
|
status: 403
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('never selects proclamation content', async () => {
|
it('never selects proclamation content', async () => {
|
||||||
await load({ locals } as never);
|
await load({ locals, url: new URL('http://x/') } as never);
|
||||||
|
|
||||||
expect(procFindMany).toHaveBeenCalledWith(
|
expect(procFindMany).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -58,34 +80,37 @@ describe('schedule load', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('maps blocks to title/category/group only', async () => {
|
it('maps blocks to title/category/group only', async () => {
|
||||||
listBlocksMock.mockResolvedValue([
|
getScheduleMock.mockResolvedValue({
|
||||||
{
|
id: 's1',
|
||||||
id: 'b1',
|
type: 'daily',
|
||||||
startMinutes: 360,
|
blocks: [
|
||||||
endMinutes: 480,
|
{
|
||||||
dynamicAudio: true,
|
id: 'b1',
|
||||||
proclamations: [
|
day: null,
|
||||||
{
|
startMinutes: 360,
|
||||||
proclamation: {
|
endMinutes: 480,
|
||||||
id: 'p1',
|
dynamicAudio: true,
|
||||||
name: 'Peace',
|
proclamations: [
|
||||||
categoryId: 2,
|
{
|
||||||
groupId: 1,
|
proclamation: {
|
||||||
content: '<p>secret</p>'
|
id: 'p1',
|
||||||
|
name: 'Peace',
|
||||||
|
categoryId: 2,
|
||||||
|
groupId: 1,
|
||||||
|
content: '<p>secret</p>'
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
]
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
]);
|
});
|
||||||
|
|
||||||
const result = (await load({ locals } as never)) as {
|
const result = (await load({ locals, url: new URL('http://x/') } as never)) as {
|
||||||
blocks: {
|
type: string | null;
|
||||||
id: string;
|
blocks: { id: string; dynamicAudio: boolean; proclamations: Record<string, unknown>[] }[];
|
||||||
dynamicAudio: boolean;
|
|
||||||
proclamations: Record<string, unknown>[];
|
|
||||||
}[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
expect(result.type).toBe('daily');
|
||||||
expect(result.blocks[0].dynamicAudio).toBe(true);
|
expect(result.blocks[0].dynamicAudio).toBe(true);
|
||||||
expect(result.blocks[0].proclamations[0]).toEqual({
|
expect(result.blocks[0].proclamations[0]).toEqual({
|
||||||
id: 'p1',
|
id: 'p1',
|
||||||
@@ -112,6 +137,7 @@ describe('schedule actions', () => {
|
|||||||
|
|
||||||
const result = await actions.createBlock!({
|
const result = await actions.createBlock!({
|
||||||
request: createFormRequest({
|
request: createFormRequest({
|
||||||
|
licenseKeyId: 'lk1',
|
||||||
startMinutes: '360',
|
startMinutes: '360',
|
||||||
endMinutes: '480',
|
endMinutes: '480',
|
||||||
proclamationIds: '[]'
|
proclamationIds: '[]'
|
||||||
@@ -131,6 +157,8 @@ describe('schedule actions', () => {
|
|||||||
|
|
||||||
const result = await actions.createBlock!({
|
const result = await actions.createBlock!({
|
||||||
request: createFormRequest({
|
request: createFormRequest({
|
||||||
|
licenseKeyId: 'lk1',
|
||||||
|
day: 'mon',
|
||||||
startMinutes: '360',
|
startMinutes: '360',
|
||||||
endMinutes: '480',
|
endMinutes: '480',
|
||||||
proclamationIds: JSON.stringify(['p1', 'p2']),
|
proclamationIds: JSON.stringify(['p1', 'p2']),
|
||||||
@@ -140,7 +168,8 @@ describe('schedule actions', () => {
|
|||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
expect(result).toEqual({ success: true });
|
expect(result).toEqual({ success: true });
|
||||||
expect(createBlockMock).toHaveBeenCalledWith('user-1', {
|
expect(createBlockMock).toHaveBeenCalledWith('lk1', {
|
||||||
|
day: 'mon',
|
||||||
startMinutes: 360,
|
startMinutes: 360,
|
||||||
endMinutes: 480,
|
endMinutes: 480,
|
||||||
proclamationIds: ['p1', 'p2'],
|
proclamationIds: ['p1', 'p2'],
|
||||||
@@ -148,27 +177,23 @@ describe('schedule actions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('createBlock defaults dynamic audio to false when the field is absent', async () => {
|
it('setType requiring confirmation returns the confirm error', async () => {
|
||||||
createBlockMock.mockResolvedValue({ ok: true, id: 'b1' });
|
setTypeMock.mockResolvedValue({ ok: false, error: 'confirm' });
|
||||||
|
|
||||||
await actions.createBlock!({
|
const result = await actions.setType!({
|
||||||
request: createFormRequest({
|
request: createFormRequest({ licenseKeyId: 'lk1', type: 'weekly' }),
|
||||||
startMinutes: '360',
|
|
||||||
endMinutes: '480',
|
|
||||||
proclamationIds: JSON.stringify(['p1'])
|
|
||||||
}),
|
|
||||||
locals
|
locals
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
expect(createBlockMock).toHaveBeenCalledWith(
|
expect(isActionFailure(result)).toBe(true);
|
||||||
'user-1',
|
if (isActionFailure(result)) {
|
||||||
expect.objectContaining({ dynamicAudio: false })
|
expect(result.data).toEqual({ error: 'confirm' });
|
||||||
);
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deleteBlock returns not found without an id', async () => {
|
it('deleteBlock returns not found without an id', async () => {
|
||||||
const result = await actions.deleteBlock!({
|
const result = await actions.deleteBlock!({
|
||||||
request: createFormRequest({}),
|
request: createFormRequest({ licenseKeyId: 'lk1' }),
|
||||||
locals
|
locals
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
@@ -178,4 +203,76 @@ describe('schedule actions', () => {
|
|||||||
}
|
}
|
||||||
expect(deleteBlockMock).not.toHaveBeenCalled();
|
expect(deleteBlockMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('copy rejects unauthenticated requests', async () => {
|
||||||
|
await expect(
|
||||||
|
actions.copy!({
|
||||||
|
request: createFormRequest({ sourceLicenseKeyId: 'lk1' }),
|
||||||
|
locals: createLocals()
|
||||||
|
} as never)
|
||||||
|
).rejects.toMatchObject({ status: 403 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy rejects a source not owned by the account', async () => {
|
||||||
|
licenseFindMany.mockResolvedValue([]); // none owned
|
||||||
|
|
||||||
|
const result = await actions.copy!({
|
||||||
|
request: createFormRequest({ sourceLicenseKeyId: 'lk-other' }),
|
||||||
|
locals
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(isActionFailure(result)).toBe(true);
|
||||||
|
if (isActionFailure(result)) expect(result.status).toBe(404);
|
||||||
|
expect(copyScheduleMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy passes the owned systems and targets and returns a summary', async () => {
|
||||||
|
licenseFindMany.mockResolvedValue([
|
||||||
|
{ id: 'lk1', systemName: 'Living Room', key: 'AAAA-BBBB-CCCC-DDDD' },
|
||||||
|
{ id: 'lk2', systemName: 'Kitchen', key: 'EEEE-FFFF-GGGG-HHHH' }
|
||||||
|
]);
|
||||||
|
copyScheduleMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
copied: 1,
|
||||||
|
targets: [{ id: 'lk2', label: 'Kitchen' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('sourceLicenseKeyId', 'lk1');
|
||||||
|
form.append('targetIds', 'lk2');
|
||||||
|
const request = new Request('http://localhost', { method: 'POST', body: form });
|
||||||
|
|
||||||
|
const result = await actions.copy!({ request, locals } as never);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
action: 'copy',
|
||||||
|
copied: 1,
|
||||||
|
targets: [{ id: 'lk2', label: 'Kitchen' }]
|
||||||
|
});
|
||||||
|
expect(copyScheduleMock).toHaveBeenCalledWith(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ id: 'lk1' }),
|
||||||
|
expect.objectContaining({ id: 'lk2' })
|
||||||
|
]),
|
||||||
|
'lk1',
|
||||||
|
['lk2']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy surfaces a service error', async () => {
|
||||||
|
copyScheduleMock.mockResolvedValue({ ok: false, error: 'Target system not found.' });
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('sourceLicenseKeyId', 'lk1');
|
||||||
|
form.append('targetIds', 'lk-bad');
|
||||||
|
const request = new Request('http://localhost', { method: 'POST', body: form });
|
||||||
|
|
||||||
|
const result = await actions.copy!({ request, locals } as never);
|
||||||
|
|
||||||
|
expect(isActionFailure(result)).toBe(true);
|
||||||
|
if (isActionFailure(result)) {
|
||||||
|
expect(result.data).toEqual({ error: 'Target system not found.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { page } from 'vitest/browser';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render } from 'vitest-browser-svelte';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
vi.mock('$app/forms', () => ({
|
||||||
|
enhance: () => () => {}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
|
const LICENSE = {
|
||||||
|
id: 'lk1',
|
||||||
|
key: 'AAAA-BBBB-CCCC-DDDD',
|
||||||
|
systemName: 'Living Room',
|
||||||
|
active: true,
|
||||||
|
hasSchedule: false
|
||||||
|
};
|
||||||
|
|
||||||
|
const block = {
|
||||||
|
id: 'b1',
|
||||||
|
day: null as string | null,
|
||||||
|
startMinutes: 6 * 60,
|
||||||
|
endMinutes: 8 * 60,
|
||||||
|
dynamicAudio: true,
|
||||||
|
proclamations: [{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 }]
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeData(overrides: Partial<PageProps['data']> = {}): PageProps['data'] {
|
||||||
|
return {
|
||||||
|
userRoleId: null,
|
||||||
|
licenses: [LICENSE],
|
||||||
|
selectedLicenseId: LICENSE.id,
|
||||||
|
type: null,
|
||||||
|
blocks: [],
|
||||||
|
categories: [{ id: 1, name: 'Physical' }],
|
||||||
|
groups: [{ id: 1, name: 'General' }],
|
||||||
|
proclamations: [{ id: 'p1', name: 'Peace', categoryId: 1, groupId: 1 }],
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mountPage(data: PageProps['data']) {
|
||||||
|
return render(Page, { params: {}, data, form: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('schedule page', () => {
|
||||||
|
it('renders a schedule loaded for the selected system (same system, new data)', async () => {
|
||||||
|
const result = mountPage(makeData());
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.element(page.getByText('No schedule. Choose Daily or Weekly to start.'))
|
||||||
|
.toBeInTheDocument();
|
||||||
|
|
||||||
|
// The server reloads `data` after an action while the selected system id
|
||||||
|
// stays the same; the view must follow the new data rather than a stale
|
||||||
|
// local mirror.
|
||||||
|
await result.rerender({ data: makeData({ type: 'daily', blocks: [block] }) });
|
||||||
|
|
||||||
|
await expect.element(page.getByText('Every day')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByText('Peace')).toBeInTheDocument();
|
||||||
|
await expect.element(page.getByLabelText('Block 6:00 AM to 8:00 AM')).toBeInTheDocument();
|
||||||
|
await expect
|
||||||
|
.element(page.getByText('No schedule. Choose Daily or Weekly to start.'))
|
||||||
|
.not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
const columns: DataTableColumn[] = [
|
const columns: DataTableColumn[] = [
|
||||||
{ label: 'Key', value: (r) => r.key },
|
{ label: 'Key', value: (r) => r.key },
|
||||||
|
{ label: 'System Name', value: (r) => (r.systemName as string | null) ?? 'Unnamed system' },
|
||||||
{
|
{
|
||||||
label: 'User',
|
label: 'User',
|
||||||
value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? ''
|
value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? ''
|
||||||
|
|||||||
@@ -4,6 +4,11 @@ import { prisma } from '$lib/db';
|
|||||||
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
|
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
|
||||||
import { rateLimit } from '$lib/server/v2-rate-limit';
|
import { rateLimit } from '$lib/server/v2-rate-limit';
|
||||||
import { serializeLicense } from '$lib/server/v2-serializers';
|
import { serializeLicense } from '$lib/server/v2-serializers';
|
||||||
|
import {
|
||||||
|
applySystemName,
|
||||||
|
normalizeSystemName,
|
||||||
|
type SystemNameStatus
|
||||||
|
} from '$lib/server/system-name';
|
||||||
|
|
||||||
const LICENSE_KEY_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
|
const LICENSE_KEY_RE = /^[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/;
|
||||||
|
|
||||||
@@ -50,6 +55,20 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
throw error(400, 'key does not belong to specified user');
|
throw error(400, 'key does not belong to specified user');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional System Name (Kiosk is the only writer). Validation always
|
||||||
|
// succeeds; a non-ok name status is advisory. An omitted field never
|
||||||
|
// changes or clears the stored name; empty/whitespace is invalid, never a
|
||||||
|
// clear.
|
||||||
|
let systemNameStatus: SystemNameStatus = 'unchanged';
|
||||||
|
if ('systemName' in body) {
|
||||||
|
const parsed = normalizeSystemName(body.systemName);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
systemNameStatus = 'invalid';
|
||||||
|
} else {
|
||||||
|
systemNameStatus = await applySystemName(storedKey, parsed.name, parsed.lower);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Record that this active key was validated. A failure to record the
|
// Record that this active key was validated. A failure to record the
|
||||||
// timestamp should not fail an otherwise valid validation.
|
// timestamp should not fail an otherwise valid validation.
|
||||||
try {
|
try {
|
||||||
@@ -64,7 +83,8 @@ export const POST: RequestHandler = async ({ request }) => {
|
|||||||
return jsonOk(
|
return jsonOk(
|
||||||
serializeLicense({
|
serializeLicense({
|
||||||
key: storedKey.key,
|
key: storedKey.key,
|
||||||
emailAddress: email
|
emailAddress: email,
|
||||||
|
systemNameStatus
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const storedKey = {
|
|||||||
id: 'key-1',
|
id: 'key-1',
|
||||||
key: 'ABCD-EFGH-IJKL-MNOP',
|
key: 'ABCD-EFGH-IJKL-MNOP',
|
||||||
active: true,
|
active: true,
|
||||||
userId: 'user-1'
|
userId: 'user-1',
|
||||||
|
systemName: null
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('POST /api/v2/license', () => {
|
describe('POST /api/v2/license', () => {
|
||||||
@@ -64,7 +65,8 @@ describe('POST /api/v2/license', () => {
|
|||||||
const body = (await res.json()) as Record<string, unknown>;
|
const body = (await res.json()) as Record<string, unknown>;
|
||||||
expect(body).toEqual({
|
expect(body).toEqual({
|
||||||
key: storedKey.key,
|
key: storedKey.key,
|
||||||
email_address: 'user@example.com'
|
email_address: 'user@example.com',
|
||||||
|
systemNameStatus: 'unchanged'
|
||||||
});
|
});
|
||||||
expect(Object.keys(body)).not.toContain('subscription_level');
|
expect(Object.keys(body)).not.toContain('subscription_level');
|
||||||
expect(mockKeyUpdate).toHaveBeenCalledWith({
|
expect(mockKeyUpdate).toHaveBeenCalledWith({
|
||||||
@@ -147,3 +149,90 @@ describe('POST /api/v2/license', () => {
|
|||||||
expect(mockKeyFindFirst).not.toHaveBeenCalled();
|
expect(mockKeyFindFirst).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('POST /api/v2/license systemName handling', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
_clearRateLimits();
|
||||||
|
mockKeyFindFirst.mockResolvedValue(storedKey);
|
||||||
|
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com' });
|
||||||
|
mockKeyUpdate.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function post(...args: unknown[]) {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
license_key: storedKey.key,
|
||||||
|
email_address: 'user@example.com'
|
||||||
|
};
|
||||||
|
if (args.length > 0) body.systemName = args[0];
|
||||||
|
const res = await POST({ request: licenseRequest(body) } as POSTEvent);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('leaves the name untouched and reports unchanged when omitted', async () => {
|
||||||
|
const res = await post();
|
||||||
|
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('unchanged');
|
||||||
|
expect(mockKeyUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockKeyUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: storedKey.id },
|
||||||
|
data: { lastValidatedAt: expect.any(Date) }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets a trimmed name, case-folded for uniqueness (ok)', async () => {
|
||||||
|
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const res = await post(' Living Room ');
|
||||||
|
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('ok');
|
||||||
|
expect(mockKeyUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: storedKey.id },
|
||||||
|
data: { systemName: 'Living Room', systemNameLower: 'living room' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports conflict without writing when another key holds the name', async () => {
|
||||||
|
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' });
|
||||||
|
|
||||||
|
const res = await post('Living Room');
|
||||||
|
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('conflict');
|
||||||
|
expect(mockKeyUpdate).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats differing casing as a conflict', async () => {
|
||||||
|
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' });
|
||||||
|
|
||||||
|
const res = await post('living room');
|
||||||
|
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('conflict');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows the same name in a different account', async () => {
|
||||||
|
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const res = await post('Living Room');
|
||||||
|
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports invalid without writing for empty, whitespace, too long, or non-string', async () => {
|
||||||
|
for (const bad of ['', ' ', 'a'.repeat(41), 42]) {
|
||||||
|
const res = await post(bad);
|
||||||
|
expect((await res.json()).systemNameStatus).toBe('invalid');
|
||||||
|
}
|
||||||
|
// Only the lastValidatedAt stamp is written; no name writes.
|
||||||
|
for (const call of mockKeyUpdate.mock.calls) {
|
||||||
|
expect(call[0].data).toEqual({ lastValidatedAt: expect.any(Date) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validation still succeeds (200) on conflict', async () => {
|
||||||
|
mockKeyFindFirst.mockResolvedValueOnce(storedKey).mockResolvedValueOnce({ id: 'other' });
|
||||||
|
|
||||||
|
const res = await post('Living Room');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { clientIp, rateLimited } from '$lib/server/v2-helpers';
|
||||||
|
import { rateLimit } from '$lib/server/v2-rate-limit';
|
||||||
|
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
|
||||||
|
import { buildSchedulePayload, type V2ScheduleType } from '$lib/server/v2-serializers';
|
||||||
|
|
||||||
|
function scheduleType(value: string | null | undefined): V2ScheduleType {
|
||||||
|
return value === 'daily' || value === 'weekly' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiosk-facing, key-only schedule read. Returns the target payload with an ETag
|
||||||
|
* (the content-derived `version`); a matching `If-None-Match` yields 304. An
|
||||||
|
* unknown License Key is 404; a key with no Schedule returns `type: null`.
|
||||||
|
*/
|
||||||
|
export const GET: RequestHandler = async ({ request, params }) => {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const rl = rateLimit({ key: `license-schedule:${ip}`, limit: 10, windowMs: 60_000 });
|
||||||
|
if (!rl.ok) {
|
||||||
|
return rateLimited(rl.retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const licenseKey = (params.licenseKey ?? '').trim();
|
||||||
|
const license = await prisma.licenseKey.findUnique({
|
||||||
|
where: { key: licenseKey },
|
||||||
|
select: { id: true }
|
||||||
|
});
|
||||||
|
if (!license) {
|
||||||
|
throw error(404, 'unknown_license');
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await getScheduleForLicense(license.id);
|
||||||
|
const type = scheduleType(schedule?.type);
|
||||||
|
const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : [];
|
||||||
|
const payload = buildSchedulePayload(type, blocks);
|
||||||
|
const etag = `"${payload.version}"`;
|
||||||
|
|
||||||
|
if (request.headers.get('if-none-match') === etag) {
|
||||||
|
return new Response(null, { status: 304, headers: { etag } });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(payload), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'content-type': 'application/json', etag }
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { isHttpError } from '@sveltejs/kit';
|
||||||
|
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
|
||||||
|
|
||||||
|
vi.mock('$lib/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
licenseKey: { findUnique: vi.fn() }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/schedule', () => ({
|
||||||
|
getScheduleForLicense: vi.fn(),
|
||||||
|
scheduleBlocksToInput: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
|
||||||
|
import { GET } from './+server';
|
||||||
|
|
||||||
|
type GETEvent = Parameters<typeof GET>[0];
|
||||||
|
|
||||||
|
const mockFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
|
||||||
|
const mockGetSchedule = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
||||||
|
const mockToInput = scheduleBlocksToInput as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const KEY = 'ABCD-EFGH-IJKL-MNOP';
|
||||||
|
|
||||||
|
function event(ifNoneMatch?: string): GETEvent {
|
||||||
|
const headers = new Headers();
|
||||||
|
if (ifNoneMatch) headers.set('if-none-match', ifNoneMatch);
|
||||||
|
return {
|
||||||
|
request: new Request(`http://localhost/api/v2/licenses/${KEY}/schedule`, { headers }),
|
||||||
|
params: { licenseKey: KEY }
|
||||||
|
} as GETEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
_clearRateLimits();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GET /api/v2/licenses/[licenseKey]/schedule', () => {
|
||||||
|
it('returns 404 for an unknown license', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue(null);
|
||||||
|
await expect(GET(event())).rejects.toSatisfy(
|
||||||
|
(e: unknown) => isHttpError(e) && e.status === 404 && e.body.message === 'unknown_license'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns type null with empty lists when there is no schedule', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({ id: 'lk1' });
|
||||||
|
mockGetSchedule.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await GET(event());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as Record<string, unknown>;
|
||||||
|
expect(body.type).toBeNull();
|
||||||
|
expect(body.daily).toEqual([]);
|
||||||
|
expect(body.blocks).toEqual([]);
|
||||||
|
expect(body.version).toBeTruthy();
|
||||||
|
expect(res.headers.get('etag')).toBe(`"${body.version}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the daily payload with an ETag', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({ id: 'lk1' });
|
||||||
|
mockGetSchedule.mockResolvedValue({ type: 'daily' });
|
||||||
|
mockToInput.mockReturnValue([
|
||||||
|
{
|
||||||
|
id: 'b1',
|
||||||
|
day: null,
|
||||||
|
startMinutes: 360,
|
||||||
|
endMinutes: 420,
|
||||||
|
dynamicAudio: true,
|
||||||
|
proclamationIds: ['p1']
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await GET(event());
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const body = (await res.json()) as { type: string; daily: unknown[]; blocks: unknown[] };
|
||||||
|
expect(body.type).toBe('daily');
|
||||||
|
expect(body.daily).toHaveLength(1);
|
||||||
|
expect(body.blocks).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 304 when If-None-Match matches the ETag', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({ id: 'lk1' });
|
||||||
|
mockGetSchedule.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const first = await GET(event());
|
||||||
|
const etag = first.headers.get('etag') as string;
|
||||||
|
const second = await GET(event(etag));
|
||||||
|
|
||||||
|
expect(second.status).toBe(304);
|
||||||
|
expect(second.headers.get('etag')).toBe(etag);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 200 when If-None-Match does not match', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({ id: 'lk1' });
|
||||||
|
mockGetSchedule.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const res = await GET(event('"stale"'));
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { error } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { clientIp, jsonOk, rateLimited, readJsonBody } from '$lib/server/v2-helpers';
|
||||||
|
import { rateLimit } from '$lib/server/v2-rate-limit';
|
||||||
|
import { applySystemName, normalizeSystemName } from '$lib/server/system-name';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kiosk-facing. Sets the System Name for a License Key. The Kiosk is the only
|
||||||
|
* writer; the Portal never calls this. Idempotent: re-sending the current name
|
||||||
|
* returns 200 without a write. Responds 400 `invalid_name`, 404
|
||||||
|
* `unknown_license`, or 409 `name_taken`.
|
||||||
|
*/
|
||||||
|
export const PUT: RequestHandler = async ({ request, params }) => {
|
||||||
|
const ip = clientIp(request);
|
||||||
|
const rl = rateLimit({ key: `system-name:${ip}`, limit: 10, windowMs: 60_000 });
|
||||||
|
if (!rl.ok) {
|
||||||
|
return rateLimited(rl.retryAfterMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const licenseKey = (params.licenseKey ?? '').trim();
|
||||||
|
const body = (await readJsonBody(request)) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const parsed = normalizeSystemName(body.systemName);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
throw error(400, 'invalid_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
const license = await prisma.licenseKey.findUnique({ where: { key: licenseKey } });
|
||||||
|
if (!license) {
|
||||||
|
throw error(404, 'unknown_license');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await applySystemName(license, parsed.name, parsed.lower);
|
||||||
|
if (status === 'conflict') {
|
||||||
|
throw error(409, 'name_taken');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonOk({ systemName: parsed.name, systemNameStatus: 'ok' });
|
||||||
|
};
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { isHttpError } from '@sveltejs/kit';
|
||||||
|
import { _clearRateLimits } from '$lib/server/v2-rate-limit';
|
||||||
|
|
||||||
|
vi.mock('$lib/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
licenseKey: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn() }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { prisma } from '$lib/db';
|
||||||
|
import { PUT } from './+server';
|
||||||
|
|
||||||
|
type PUTEvent = Parameters<typeof PUT>[0];
|
||||||
|
|
||||||
|
const mockFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
|
||||||
|
const mockFindFirst = prisma.licenseKey.findFirst as ReturnType<typeof vi.fn>;
|
||||||
|
const mockUpdate = prisma.licenseKey.update as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
const KEY = 'ABCD-EFGH-IJKL-MNOP';
|
||||||
|
|
||||||
|
const storedKey = {
|
||||||
|
id: 'key-1',
|
||||||
|
key: KEY,
|
||||||
|
active: true,
|
||||||
|
userId: 'user-1',
|
||||||
|
systemName: null as string | null
|
||||||
|
};
|
||||||
|
|
||||||
|
function putRequest(body: unknown): Request {
|
||||||
|
return new Request(`http://localhost/api/v2/licenses/${KEY}/system-name`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function event(body: unknown): PUTEvent {
|
||||||
|
return { request: putRequest(body), params: { licenseKey: KEY } } as PUTEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectHttpError(promise: unknown, status: number, message: string) {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (e) {
|
||||||
|
expect(isHttpError(e)).toBe(true);
|
||||||
|
const err = e as { status: number; body: { message: string } };
|
||||||
|
expect(err.status).toBe(status);
|
||||||
|
expect(err.body.message).toBe(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('expected handler to throw an HttpError');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
_clearRateLimits();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PUT /api/v2/licenses/[licenseKey]/system-name', () => {
|
||||||
|
it('sets the name and returns it', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue(storedKey);
|
||||||
|
mockFindFirst.mockResolvedValue(null);
|
||||||
|
mockUpdate.mockResolvedValue({});
|
||||||
|
|
||||||
|
const res = await PUT(event({ systemName: ' Living Room ' }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ systemName: 'Living Room', systemNameStatus: 'ok' });
|
||||||
|
expect(mockUpdate).toHaveBeenCalledWith({
|
||||||
|
where: { id: 'key-1' },
|
||||||
|
data: { systemName: 'Living Room', systemNameLower: 'living room' }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is idempotent when the name is unchanged', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue({ ...storedKey, systemName: 'Living Room' });
|
||||||
|
|
||||||
|
const res = await PUT(event({ systemName: 'Living Room' }));
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ systemName: 'Living Room', systemNameStatus: 'ok' });
|
||||||
|
expect(mockUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an invalid name', async () => {
|
||||||
|
await expectHttpError(PUT(event({ systemName: ' ' })), 400, 'invalid_name');
|
||||||
|
await expectHttpError(PUT(event({ systemName: '' })), 400, 'invalid_name');
|
||||||
|
await expectHttpError(PUT(event({ systemName: 'a'.repeat(41) })), 400, 'invalid_name');
|
||||||
|
await expectHttpError(PUT(event({})), 400, 'invalid_name');
|
||||||
|
expect(mockFindUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 404 for an unknown license', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expectHttpError(PUT(event({ systemName: 'Den' })), 404, 'unknown_license');
|
||||||
|
expect(mockUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 409 when another license on the account holds the name', async () => {
|
||||||
|
mockFindUnique.mockResolvedValue(storedKey);
|
||||||
|
mockFindFirst.mockResolvedValue({ id: 'other' });
|
||||||
|
|
||||||
|
await expectHttpError(PUT(event({ systemName: 'Den' })), 409, 'name_taken');
|
||||||
|
expect(mockUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,16 +3,24 @@ import type { RequestHandler } from './$types';
|
|||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers';
|
import { clientIp, jsonOk, rateLimited } from '$lib/server/v2-helpers';
|
||||||
import { rateLimit } from '$lib/server/v2-rate-limit';
|
import { rateLimit } from '$lib/server/v2-rate-limit';
|
||||||
import { listBlocks } from '$lib/server/schedule';
|
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
|
||||||
import { serializeScheduleBlock } from '$lib/server/v2-serializers';
|
import { buildSchedulePayload } from '$lib/server/v2-serializers';
|
||||||
|
|
||||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
function scheduleType(value: string | null | undefined) {
|
||||||
|
return value === 'daily' || value === 'weekly' ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with
|
* Machine-to-app endpoint used by the RadiantWave kiosk. Authenticates with
|
||||||
* `email` + `licenseKey` query parameters and validates only that the pair
|
* `email` + `licenseKey` query parameters and validates only that the pair
|
||||||
* belong together — license active/entitlement is deliberately not checked
|
* belong together — license active/entitlement is deliberately not checked
|
||||||
* here, since an invalid license would already lock the kiosk out.
|
* here, since an invalid license would already lock the kiosk out.
|
||||||
|
*
|
||||||
|
* Extended additively with `version`, `type`, `daily`, and `weekly`. `email`
|
||||||
|
* and `blocks` keep their exact previous meaning (daily flattening; empty for
|
||||||
|
* weekly), so old Kiosks are unaffected. No ETag/304 here.
|
||||||
*/
|
*/
|
||||||
export const GET: RequestHandler = async ({ request, url }) => {
|
export const GET: RequestHandler = async ({ request, url }) => {
|
||||||
const ip = clientIp(request);
|
const ip = clientIp(request);
|
||||||
@@ -50,14 +58,10 @@ export const GET: RequestHandler = async ({ request, url }) => {
|
|||||||
throw error(400, "this key doesn't appear to belong to you");
|
throw error(400, "this key doesn't appear to belong to you");
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocks = await listBlocks(user.id);
|
const schedule = await getScheduleForLicense(storedKey.id);
|
||||||
|
const type = scheduleType(schedule?.type);
|
||||||
|
const blocks = schedule ? scheduleBlocksToInput(schedule.blocks) : [];
|
||||||
|
const payload = buildSchedulePayload(type, blocks);
|
||||||
|
|
||||||
const serialized = blocks.map((block) =>
|
return jsonOk({ email, ...payload });
|
||||||
serializeScheduleBlock(
|
|
||||||
block,
|
|
||||||
block.proclamations.map((entry) => entry.proclamation.id)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return jsonOk({ email, blocks: serialized });
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,19 +3,24 @@ import { _clearRateLimits } from '$lib/server/v2-rate-limit';
|
|||||||
|
|
||||||
vi.mock('$lib/db', () => ({
|
vi.mock('$lib/db', () => ({
|
||||||
prisma: {
|
prisma: {
|
||||||
schedule: { findUnique: vi.fn(), create: vi.fn() },
|
|
||||||
block: { findMany: vi.fn() },
|
|
||||||
licenseKey: { findUnique: vi.fn() },
|
licenseKey: { findUnique: vi.fn() },
|
||||||
user: { findUnique: vi.fn() }
|
user: { findUnique: vi.fn() }
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/server/schedule', () => ({
|
||||||
|
getScheduleForLicense: vi.fn(),
|
||||||
|
scheduleBlocksToInput: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
import { prisma } from '$lib/db';
|
import { prisma } from '$lib/db';
|
||||||
|
import { getScheduleForLicense, scheduleBlocksToInput } from '$lib/server/schedule';
|
||||||
import { GET } from './+server';
|
import { GET } from './+server';
|
||||||
|
|
||||||
const licenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
|
const licenseKeyFindUnique = prisma.licenseKey.findUnique as ReturnType<typeof vi.fn>;
|
||||||
const userFindUnique = prisma.user.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>;
|
const getScheduleMock = getScheduleForLicense as ReturnType<typeof vi.fn>;
|
||||||
|
const toInputMock = scheduleBlocksToInput as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
function call(params: Record<string, string>) {
|
function call(params: Record<string, string>) {
|
||||||
const url = new URL('http://localhost/api/v2/schedule');
|
const url = new URL('http://localhost/api/v2/schedule');
|
||||||
@@ -29,7 +34,8 @@ const KEY = 'AAAA-BBBB-CCCC-DDDD';
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
_clearRateLimits();
|
_clearRateLimits();
|
||||||
blockFindMany.mockResolvedValue([]);
|
getScheduleMock.mockResolvedValue(null);
|
||||||
|
toInputMock.mockReturnValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('GET /api/v2/schedule auth', () => {
|
describe('GET /api/v2/schedule auth', () => {
|
||||||
@@ -50,7 +56,7 @@ describe('GET /api/v2/schedule auth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an unknown user', async () => {
|
it('rejects an unknown user', async () => {
|
||||||
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
|
licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null });
|
||||||
userFindUnique.mockResolvedValue(null);
|
userFindUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
|
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
|
||||||
@@ -60,7 +66,12 @@ describe('GET /api/v2/schedule auth', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a key that does not belong to the email account', async () => {
|
it('rejects a key that does not belong to the email account', async () => {
|
||||||
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'someone-else', email: null });
|
licenseKeyFindUnique.mockResolvedValue({
|
||||||
|
id: 'lk1',
|
||||||
|
key: KEY,
|
||||||
|
userId: 'someone-else',
|
||||||
|
email: null
|
||||||
|
});
|
||||||
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
||||||
|
|
||||||
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
|
await expect(call({ email: 'jane@example.com', licenseKey: KEY })).rejects.toMatchObject({
|
||||||
@@ -88,52 +99,62 @@ describe('GET /api/v2/schedule auth', () => {
|
|||||||
describe('GET /api/v2/schedule success', () => {
|
describe('GET /api/v2/schedule success', () => {
|
||||||
it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => {
|
it('returns only proclamation IDs, marking an overnight block, even when the key is inactive', async () => {
|
||||||
licenseKeyFindUnique.mockResolvedValue({
|
licenseKeyFindUnique.mockResolvedValue({
|
||||||
|
id: 'lk1',
|
||||||
key: KEY,
|
key: KEY,
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
email: null,
|
email: null,
|
||||||
active: false
|
active: false
|
||||||
});
|
});
|
||||||
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
||||||
blockFindMany.mockResolvedValue([
|
getScheduleMock.mockResolvedValue({ type: 'daily' });
|
||||||
|
toInputMock.mockReturnValue([
|
||||||
{
|
{
|
||||||
id: 'block-1',
|
id: 'block-1',
|
||||||
|
day: null,
|
||||||
startMinutes: 21 * 60,
|
startMinutes: 21 * 60,
|
||||||
endMinutes: 9 * 60,
|
endMinutes: 9 * 60,
|
||||||
dynamicAudio: true,
|
dynamicAudio: true,
|
||||||
proclamations: [{ proclamation: { id: 'p1' } }, { proclamation: { id: 'p2' } }]
|
proclamationIds: ['p1', 'p2']
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
|
const response = await call({ email: 'jane@example.com', licenseKey: KEY });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
await expect(response.json()).resolves.toEqual({
|
const body = (await response.json()) as { email: string; blocks: unknown[]; type: string };
|
||||||
email: 'jane@example.com',
|
expect(body.email).toBe('jane@example.com');
|
||||||
blocks: [
|
expect(body.blocks).toEqual([
|
||||||
{
|
{
|
||||||
id: 'block-1',
|
id: 'block-1',
|
||||||
startTime: '21:00',
|
startTime: '21:00',
|
||||||
startMinutes: 1260,
|
startMinutes: 1260,
|
||||||
endTime: '09:00',
|
endTime: '09:00',
|
||||||
endMinutes: 540,
|
endMinutes: 540,
|
||||||
overnight: true,
|
overnight: true,
|
||||||
durationMinutes: 720,
|
durationMinutes: 720,
|
||||||
dynamicAudio: true,
|
dynamicAudio: true,
|
||||||
proclamationIds: ['p1', 'p2']
|
proclamationIds: ['p1', 'p2']
|
||||||
}
|
}
|
||||||
]
|
]);
|
||||||
});
|
// Additive fields for new Kiosks.
|
||||||
|
expect(body.type).toBe('daily');
|
||||||
|
expect(body).toHaveProperty('version');
|
||||||
|
expect(body).toHaveProperty('daily');
|
||||||
|
expect(body).toHaveProperty('weekly');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns IDs for every selected proclamation regardless of frequency', async () => {
|
it('returns IDs for every selected proclamation regardless of frequency', async () => {
|
||||||
licenseKeyFindUnique.mockResolvedValue({ key: KEY, userId: 'user-1', email: null });
|
licenseKeyFindUnique.mockResolvedValue({ id: 'lk1', key: KEY, userId: 'user-1', email: null });
|
||||||
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
userFindUnique.mockResolvedValue({ id: 'user-1', email: 'jane@example.com' });
|
||||||
blockFindMany.mockResolvedValue([
|
getScheduleMock.mockResolvedValue({ type: 'daily' });
|
||||||
|
toInputMock.mockReturnValue([
|
||||||
{
|
{
|
||||||
id: 'block-1',
|
id: 'block-1',
|
||||||
|
day: null,
|
||||||
startMinutes: 0,
|
startMinutes: 0,
|
||||||
endMinutes: 60,
|
endMinutes: 60,
|
||||||
proclamations: [{ proclamation: { id: 'p1' } }]
|
dynamicAudio: false,
|
||||||
|
proclamationIds: ['p1']
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -157,6 +178,7 @@ describe('GET /api/v2/schedule success', () => {
|
|||||||
|
|
||||||
it('accepts the legacy `key` query alias and an email-only license', async () => {
|
it('accepts the legacy `key` query alias and an email-only license', async () => {
|
||||||
licenseKeyFindUnique.mockResolvedValue({
|
licenseKeyFindUnique.mockResolvedValue({
|
||||||
|
id: 'lk1',
|
||||||
key: KEY,
|
key: KEY,
|
||||||
userId: null,
|
userId: null,
|
||||||
email: 'jane@example.com'
|
email: 'jane@example.com'
|
||||||
@@ -166,6 +188,9 @@ describe('GET /api/v2/schedule success', () => {
|
|||||||
const response = await call({ email: 'jane@example.com', key: KEY });
|
const response = await call({ email: 'jane@example.com', key: KEY });
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
await expect(response.json()).resolves.toEqual({ email: 'jane@example.com', blocks: [] });
|
const body = (await response.json()) as { email: string; blocks: unknown[]; type: null };
|
||||||
|
expect(body.email).toBe('jane@example.com');
|
||||||
|
expect(body.blocks).toEqual([]);
|
||||||
|
expect(body.type).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import Window from '$lib/components/containers/Window.svelte';
|
import Window from '$lib/components/containers/Window.svelte';
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST" class="flex flex-col gap-4">
|
<form method="POST" use:enhance class="flex flex-col gap-4">
|
||||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Forgot Password</h1>
|
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Forgot Password</h1>
|
||||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||||
Enter your email and we'll send you a link to reset your password.
|
Enter your email and we'll send you a link to reset your password.
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
|
|
||||||
let verified = $derived(page.url.searchParams.get('verified') === 'true');
|
let verified = $derived(page.url.searchParams.get('verified') === 'true');
|
||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
const { error: authError } = await authClient.signIn.email({
|
const { error: authError } = await authClient.signIn.email({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import Window from '$lib/components/containers/Window.svelte';
|
import Window from '$lib/components/containers/Window.svelte';
|
||||||
import { authClient } from '$lib/auth-client';
|
import { authClient } from '$lib/auth-client';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST" class="flex flex-col gap-4">
|
<form method="POST" use:enhance class="flex flex-col gap-4">
|
||||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Create Account</h1>
|
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">Create Account</h1>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
import Window from '$lib/components/containers/Window.svelte';
|
import Window from '$lib/components/containers/Window.svelte';
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form method="POST" class="flex flex-col gap-4">
|
<form method="POST" use:enhance class="flex flex-col gap-4">
|
||||||
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
<h1 class="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
{data.invite ? 'Set Your Password' : 'Reset Password'}
|
{data.invite ? 'Set Your Password' : 'Reset Password'}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
Reference in New Issue
Block a user