nixamp 0.27.1 → 0.27.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -20
- package/backtoschool/dist/assets/{index-6vtCMzi1.css → index-Bot4FEXQ.css} +1 -1
- package/backtoschool/dist/assets/index-Cjai7LgT.js +93 -0
- package/backtoschool/dist/index.html +36 -9
- package/dist/accounts.d.ts +2 -0
- package/dist/accounts.js +9 -2
- package/dist/classroom.d.ts +15 -0
- package/dist/classroom.js +54 -0
- package/dist/event-writer.d.ts +39 -0
- package/dist/event-writer.js +139 -0
- package/dist/live-api.d.ts +2 -0
- package/dist/live-api.js +37 -2
- package/dist/live-events.d.ts +7 -1
- package/dist/live-events.js +88 -12
- package/dist/live-interpreter.d.ts +3 -1
- package/dist/live-interpreter.js +109 -83
- package/dist/live-recovery.d.ts +6 -0
- package/dist/live-recovery.js +11 -0
- package/dist/live-voice.d.ts +1 -1
- package/dist/live-voice.js +7 -7
- package/dist/mail-sender.d.ts +9 -0
- package/dist/mail-sender.js +11 -0
- package/dist/notify.d.ts +3 -3
- package/dist/notify.js +8 -5
- package/dist/password-reset-page.d.ts +2 -0
- package/dist/password-reset-page.js +106 -0
- package/dist/password-reset.d.ts +34 -0
- package/dist/password-reset.js +121 -0
- package/dist/server.d.ts +5 -0
- package/dist/server.js +109 -3
- package/dist/shared-translation.d.ts +1 -0
- package/dist/shared-translation.js +57 -40
- package/dist/tokens.d.ts +2 -0
- package/dist/tokens.js +2 -0
- package/dist/translation-passes.d.ts +13 -8
- package/dist/translation-passes.js +31 -20
- package/dist/upgrade-allowance.d.ts +23 -0
- package/dist/upgrade-allowance.js +70 -0
- package/package.json +1 -1
- package/src/accounts.ts +8 -2
- package/src/classroom.ts +52 -0
- package/src/event-writer.ts +126 -0
- package/src/live-api.ts +26 -2
- package/src/live-events.ts +81 -13
- package/src/live-interpreter.ts +87 -68
- package/src/live-recovery.ts +10 -0
- package/src/live-voice.ts +7 -7
- package/src/mail-sender.ts +12 -0
- package/src/notify.ts +10 -7
- package/src/password-reset-page.ts +108 -0
- package/src/password-reset.ts +134 -0
- package/src/server.ts +91 -3
- package/src/shared-translation.ts +47 -33
- package/src/tokens.ts +3 -0
- package/src/translation-passes.ts +35 -25
- package/src/types/auth-system.d.ts +10 -0
- package/src/upgrade-allowance.ts +69 -0
- package/web/dist/assets/background.worker-Hz7oaQ8X.js +547 -0
- package/web/dist/assets/{hls-3VKVEQE3-C1ttVP7K.js → hls-3VKVEQE3-_BiJh76x.js} +1 -1
- package/web/dist/assets/index-BgLxkXKe.js +3 -0
- package/web/dist/assets/{index-BmR7T0zX.css → index-D34dTY18.css} +1 -1
- package/web/dist/assets/{mpegts-CVYPgepU.js → mpegts-CV0glck7.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-Dw2p8qGf.js → mpegts-LO6RVLD6-iuTMg-O6.js} +1 -1
- package/web/dist/index.html +6 -5
- package/web/dist/sw.js +7 -7
- package/backtoschool/dist/assets/index-D7Oas1m5.js +0 -88
- package/web/dist/assets/background.worker-DH8ag5Q4.js +0 -547
- package/web/dist/assets/index-3WZwGw04.js +0 -3
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export interface EventDraft {
|
|
2
|
+
title: string;
|
|
3
|
+
description: string;
|
|
4
|
+
topic: string;
|
|
5
|
+
}
|
|
6
|
+
export interface WriterInput extends EventDraft {
|
|
7
|
+
hostName: string;
|
|
8
|
+
startsAt: string;
|
|
9
|
+
timezone: string;
|
|
10
|
+
duration: string;
|
|
11
|
+
recurrence: string;
|
|
12
|
+
}
|
|
13
|
+
export interface EventDraftResult {
|
|
14
|
+
draft: EventDraft;
|
|
15
|
+
provider: "openai" | "claude";
|
|
16
|
+
}
|
|
17
|
+
export declare class EventWriterError extends Error {
|
|
18
|
+
readonly status: number;
|
|
19
|
+
readonly retryAfter?: number | undefined;
|
|
20
|
+
constructor(message: string, status: number, retryAfter?: number | undefined);
|
|
21
|
+
}
|
|
22
|
+
export declare function writerInput(value: unknown): WriterInput;
|
|
23
|
+
export interface EventWriterOptions {
|
|
24
|
+
openaiKey?: string;
|
|
25
|
+
anthropicKey?: string;
|
|
26
|
+
openaiModel?: string;
|
|
27
|
+
claudeModel?: string;
|
|
28
|
+
fetch?: typeof globalThis.fetch;
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
}
|
|
31
|
+
/** Server-side only. Drafting has no access to the event store and cannot publish. */
|
|
32
|
+
export declare class EventWriter {
|
|
33
|
+
private readonly options;
|
|
34
|
+
private readonly guard;
|
|
35
|
+
private readonly active;
|
|
36
|
+
constructor(options: EventWriterOptions);
|
|
37
|
+
draft(accountId: string, value: unknown, signal?: AbortSignal): Promise<EventDraftResult>;
|
|
38
|
+
private generate;
|
|
39
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { Guard } from "./guard.js";
|
|
2
|
+
export class EventWriterError extends Error {
|
|
3
|
+
status;
|
|
4
|
+
retryAfter;
|
|
5
|
+
constructor(message, status, retryAfter) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.retryAfter = retryAfter;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
class Refused extends EventWriterError {
|
|
12
|
+
constructor() { super("The writer could not draft these notes. Try rephrasing the event details.", 422); }
|
|
13
|
+
}
|
|
14
|
+
const LIMITS = { title: 160, description: 5000, topic: 100, hostName: 100, startsAt: 40, timezone: 100, duration: 20, recurrence: 20 };
|
|
15
|
+
export function writerInput(value) {
|
|
16
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
17
|
+
throw new EventWriterError("Send event details as an object.", 400);
|
|
18
|
+
const source = value;
|
|
19
|
+
const result = {};
|
|
20
|
+
for (const key of Object.keys(LIMITS)) {
|
|
21
|
+
const field = source[key] ?? "";
|
|
22
|
+
if (typeof field !== "string" || field.length > LIMITS[key])
|
|
23
|
+
throw new EventWriterError(`The ${key} field is too long or is not text.`, 422);
|
|
24
|
+
result[key] = field.trim();
|
|
25
|
+
}
|
|
26
|
+
if (!result.title && !result.description && !result.topic)
|
|
27
|
+
throw new EventWriterError("Add a title, description, or topic first, then click Write with AI.", 422);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
const SCHEMA = {
|
|
31
|
+
type: "object", properties: { title: { type: "string" }, description: { type: "string" }, topic: { type: "string" } },
|
|
32
|
+
required: ["title", "description", "topic"], additionalProperties: false,
|
|
33
|
+
};
|
|
34
|
+
const INSTRUCTIONS = `You write useful, welcoming event details for BackToSchool.help, a live learning platform.
|
|
35
|
+
Use the supplied form text as the writing prompt. Rough notes and requests in that text describe what the host wants to teach.
|
|
36
|
+
Return a clear title (at most 160 characters), a description (at most 5000 characters, usually 100–250 words), and a short topic (at most 100 characters).
|
|
37
|
+
Write in the language of the host's notes. Describe what students will learn and what will happen, using only information supported by the notes.
|
|
38
|
+
Keep concrete names and facts. Do not invent dates, prices, qualifications, prerequisites, links, credentials, or promises. Omit unknown logistics. Do not expand into unrelated subjects.
|
|
39
|
+
The schedule and host name are context only; never propose changing them. Return plain text in the required JSON fields, with no HTML. Short paragraphs or simple bullet lists are fine.`;
|
|
40
|
+
function draftFrom(text) {
|
|
41
|
+
const value = JSON.parse(text);
|
|
42
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
43
|
+
throw new Error("invalid draft");
|
|
44
|
+
const row = value;
|
|
45
|
+
const draft = {};
|
|
46
|
+
for (const key of ["title", "description", "topic"]) {
|
|
47
|
+
const field = row[key];
|
|
48
|
+
if (typeof field !== "string" || !field.trim() || field.length > LIMITS[key])
|
|
49
|
+
throw new Error("invalid draft");
|
|
50
|
+
draft[key] = field.trim();
|
|
51
|
+
}
|
|
52
|
+
return draft;
|
|
53
|
+
}
|
|
54
|
+
/** Server-side only. Drafting has no access to the event store and cannot publish. */
|
|
55
|
+
export class EventWriter {
|
|
56
|
+
options;
|
|
57
|
+
guard = new Guard();
|
|
58
|
+
active = new Set();
|
|
59
|
+
constructor(options) {
|
|
60
|
+
this.options = options;
|
|
61
|
+
}
|
|
62
|
+
async draft(accountId, value, signal) {
|
|
63
|
+
if (!accountId)
|
|
64
|
+
throw new EventWriterError("Sign in to use the AI writer.", 401);
|
|
65
|
+
const input = writerInput(value);
|
|
66
|
+
if (!this.options.openaiKey && !this.options.anthropicKey)
|
|
67
|
+
throw new EventWriterError("The AI writer is temporarily unavailable. You can keep writing here.", 503);
|
|
68
|
+
if (this.active.has(accountId))
|
|
69
|
+
throw new EventWriterError("A draft is already being written. Wait for it or cancel it first.", 429, 5);
|
|
70
|
+
if (this.active.size >= 4)
|
|
71
|
+
throw new EventWriterError("The AI writer is busy. Try again in a moment.", 503, 10);
|
|
72
|
+
const limit = this.guard.check(accountId, { allowed: 6, windowMs: 10 * 60_000 });
|
|
73
|
+
if (!limit.ok)
|
|
74
|
+
throw new EventWriterError("You have requested several drafts. Try again in a few minutes.", 429, limit.retryAfter);
|
|
75
|
+
this.active.add(accountId);
|
|
76
|
+
try {
|
|
77
|
+
for (const provider of ["openai", "claude"]) {
|
|
78
|
+
const key = provider === "openai" ? this.options.openaiKey : this.options.anthropicKey;
|
|
79
|
+
if (!key)
|
|
80
|
+
continue;
|
|
81
|
+
if (signal?.aborted)
|
|
82
|
+
throw new EventWriterError("Draft cancelled.", 499);
|
|
83
|
+
try {
|
|
84
|
+
const draft = await this.generate(provider, key, input, signal);
|
|
85
|
+
if (signal?.aborted)
|
|
86
|
+
throw new EventWriterError("Draft cancelled.", 499);
|
|
87
|
+
return { draft, provider };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (signal?.aborted)
|
|
91
|
+
throw new EventWriterError("Draft cancelled.", 499);
|
|
92
|
+
// Refusals are a response to the content, not a provider outage.
|
|
93
|
+
if (error instanceof Refused)
|
|
94
|
+
throw error;
|
|
95
|
+
// Never log prompts, credentials, or a provider's raw response body.
|
|
96
|
+
console.warn(`nixamp: event writer ${provider} request failed`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
throw new EventWriterError("The AI writer could not finish this draft. Your text is unchanged; please try again.", 503);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
this.active.delete(accountId);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async generate(provider, key, input, signal) {
|
|
106
|
+
const timeout = AbortSignal.timeout(this.options.timeoutMs ?? 25_000);
|
|
107
|
+
const requestSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
|
|
108
|
+
const prompt = JSON.stringify(input);
|
|
109
|
+
const openai = provider === "openai";
|
|
110
|
+
const response = await (this.options.fetch ?? globalThis.fetch)(openai ? "https://api.openai.com/v1/responses" : "https://api.anthropic.com/v1/messages", {
|
|
111
|
+
method: "POST", signal: requestSignal,
|
|
112
|
+
headers: { "content-type": "application/json", ...(openai ? { authorization: `Bearer ${key}` } : { "x-api-key": key, "anthropic-version": "2023-06-01" }) },
|
|
113
|
+
body: JSON.stringify(openai ? {
|
|
114
|
+
model: this.options.openaiModel ?? "gpt-5-mini", store: false,
|
|
115
|
+
instructions: INSTRUCTIONS, input: [{ role: "user", content: prompt }],
|
|
116
|
+
reasoning: { effort: "minimal" }, max_output_tokens: 2048,
|
|
117
|
+
text: { format: { type: "json_schema", name: "event_draft", strict: true, schema: SCHEMA } },
|
|
118
|
+
} : {
|
|
119
|
+
model: this.options.claudeModel ?? "claude-haiku-4-5", max_tokens: 2048,
|
|
120
|
+
system: INSTRUCTIONS, messages: [{ role: "user", content: prompt }],
|
|
121
|
+
output_config: { format: { type: "json_schema", schema: SCHEMA } },
|
|
122
|
+
}),
|
|
123
|
+
});
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
await response.body?.cancel();
|
|
126
|
+
throw new Error("provider unavailable");
|
|
127
|
+
}
|
|
128
|
+
const body = await response.json();
|
|
129
|
+
const blocks = openai
|
|
130
|
+
? (Array.isArray(body["output"]) ? body["output"].flatMap(item => item?.type === "message" && Array.isArray(item.content) ? item.content : []) : [])
|
|
131
|
+
: (Array.isArray(body["content"]) ? body["content"] : []);
|
|
132
|
+
if (body["stop_reason"] === "refusal" || blocks.some(block => block?.type === "refusal"))
|
|
133
|
+
throw new Refused();
|
|
134
|
+
if (openai ? body["status"] !== "completed" : body["stop_reason"] !== "end_turn")
|
|
135
|
+
throw new Error("incomplete draft");
|
|
136
|
+
const text = blocks.filter(block => block?.type === (openai ? "output_text" : "text")).map(block => typeof block.text === "string" ? block.text : "").join("");
|
|
137
|
+
return draftFrom(text);
|
|
138
|
+
}
|
|
139
|
+
}
|
package/dist/live-api.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type EventWriter } from "./event-writer.ts";
|
|
1
2
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
3
|
import { type Accounts } from "./accounts.ts";
|
|
3
4
|
import { type LiveEvent, type LiveEvents } from "./live-events.ts";
|
|
@@ -6,6 +7,7 @@ import { type Tickets } from "./tickets.ts";
|
|
|
6
7
|
import { type Rooms } from "./rooms.ts";
|
|
7
8
|
export interface LiveApiOptions {
|
|
8
9
|
events: LiveEvents;
|
|
10
|
+
eventWriter?: EventWriter;
|
|
9
11
|
accounts?: Accounts;
|
|
10
12
|
layouts?: Layouts;
|
|
11
13
|
rooms?: Rooms;
|
package/dist/live-api.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { EventWriterError } from "./event-writer.js";
|
|
1
2
|
import { tokenFrom } from "./accounts.js";
|
|
2
3
|
import { INVITATION_STATES, LIVE_EVENT_KINDS, LIVE_EVENT_STATUSES, LiveEventError, isTicketed, } from "./live-events.js";
|
|
3
4
|
import { LAYOUT_SCOPES, LayoutError, PANEL_REGISTRY, layoutNameFor, presetLayout, } from "./layouts.js";
|
|
@@ -19,13 +20,13 @@ function json(response, code, body) {
|
|
|
19
20
|
});
|
|
20
21
|
response.end(value);
|
|
21
22
|
}
|
|
22
|
-
async function body(request) {
|
|
23
|
+
async function body(request, limit = 64 * 1024) {
|
|
23
24
|
const chunks = [];
|
|
24
25
|
let size = 0;
|
|
25
26
|
for await (const chunk of request) {
|
|
26
27
|
const buffer = chunk;
|
|
27
28
|
size += buffer.length;
|
|
28
|
-
if (size >
|
|
29
|
+
if (size > limit)
|
|
29
30
|
throw new LiveEventError("body too large", 413);
|
|
30
31
|
chunks.push(buffer);
|
|
31
32
|
}
|
|
@@ -187,6 +188,32 @@ export async function handleLiveApi(request, response, url, options) {
|
|
|
187
188
|
!path.startsWith("/api/v1/layout-presets"))
|
|
188
189
|
return false;
|
|
189
190
|
try {
|
|
191
|
+
if (path === "/api/v1/events/ai-draft") {
|
|
192
|
+
if (request.method !== "POST") {
|
|
193
|
+
json(response, 405, { error: "POST only" });
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
const account = await requiredAccount(request, response, options);
|
|
197
|
+
if (!account)
|
|
198
|
+
return true;
|
|
199
|
+
if (!options.eventWriter) {
|
|
200
|
+
json(response, 503, { error: "The AI writer is temporarily unavailable. You can keep writing here." });
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
const input = await body(request, 12 * 1024);
|
|
204
|
+
const controller = new AbortController();
|
|
205
|
+
const closed = () => controller.abort();
|
|
206
|
+
response.once("close", closed);
|
|
207
|
+
try {
|
|
208
|
+
const result = await options.eventWriter.draft(account.id, input, controller.signal);
|
|
209
|
+
if (!response.destroyed)
|
|
210
|
+
json(response, 200, result);
|
|
211
|
+
}
|
|
212
|
+
finally {
|
|
213
|
+
response.off("close", closed);
|
|
214
|
+
}
|
|
215
|
+
return true;
|
|
216
|
+
}
|
|
190
217
|
if (path === "/api/v1/events") {
|
|
191
218
|
if (request.method === "GET") {
|
|
192
219
|
const account = await signedIn(request, options.accounts);
|
|
@@ -559,6 +586,14 @@ export async function handleLiveApi(request, response, url, options) {
|
|
|
559
586
|
return true;
|
|
560
587
|
}
|
|
561
588
|
catch (error) {
|
|
589
|
+
if (error instanceof EventWriterError) {
|
|
590
|
+
if (!response.destroyed) {
|
|
591
|
+
if (error.retryAfter)
|
|
592
|
+
response.setHeader("retry-after", String(error.retryAfter));
|
|
593
|
+
json(response, error.status, { error: error.message });
|
|
594
|
+
}
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
562
597
|
if (error instanceof LiveEventError || error instanceof LayoutError || error instanceof RoomError) {
|
|
563
598
|
json(response, error.status, { error: error.message });
|
|
564
599
|
return true;
|
package/dist/live-events.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ClassroomSettings } from "./classroom.ts";
|
|
1
2
|
import type { Queryable } from "./follows.ts";
|
|
2
3
|
export declare const LIVE_EVENT_STATUSES: readonly ["draft", "scheduled", "starting", "live", "encore", "ended", "cancelled", "archived"];
|
|
3
4
|
export declare const LIVE_EVENT_VISIBILITIES: readonly ["public", "unlisted", "private"];
|
|
@@ -18,7 +19,7 @@ export type LiveEventVisibility = (typeof LIVE_EVENT_VISIBILITIES)[number];
|
|
|
18
19
|
export type LiveEventKind = (typeof LIVE_EVENT_KINDS)[number];
|
|
19
20
|
export type InvitationRole = (typeof INVITATION_ROLES)[number];
|
|
20
21
|
export type InvitationState = (typeof INVITATION_STATES)[number];
|
|
21
|
-
export interface LiveEvent {
|
|
22
|
+
export interface LiveEvent extends ClassroomSettings {
|
|
22
23
|
id: string;
|
|
23
24
|
slug: string;
|
|
24
25
|
ownerId: string;
|
|
@@ -72,6 +73,11 @@ export interface CreatedInvitation extends EventInvitation {
|
|
|
72
73
|
}
|
|
73
74
|
export interface CreateLiveEventInput {
|
|
74
75
|
title: unknown;
|
|
76
|
+
broadcastUrl?: unknown;
|
|
77
|
+
hostName?: unknown;
|
|
78
|
+
homepageUrl?: unknown;
|
|
79
|
+
avatarUrl?: unknown;
|
|
80
|
+
recurrence?: unknown;
|
|
75
81
|
description?: unknown;
|
|
76
82
|
topic?: unknown;
|
|
77
83
|
kind?: unknown;
|
package/dist/live-events.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { classroomBroadcast, publicWebUrl } from "./classroom.js";
|
|
1
2
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
3
|
export const LIVE_EVENT_STATUSES = [
|
|
3
4
|
"draft",
|
|
@@ -85,6 +86,7 @@ const EVENT_SCHEMA = `
|
|
|
85
86
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_currency TEXT NOT NULL DEFAULT 'USD';
|
|
86
87
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS ticket_minutes INTEGER NOT NULL DEFAULT 1440;
|
|
87
88
|
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS pay_to TEXT;
|
|
89
|
+
ALTER TABLE live_events ADD COLUMN IF NOT EXISTS classroom JSONB NOT NULL DEFAULT '{}'::jsonb;
|
|
88
90
|
CREATE INDEX IF NOT EXISTS live_events_kind
|
|
89
91
|
ON live_events (kind, status, starts_at);
|
|
90
92
|
|
|
@@ -101,7 +103,7 @@ const EVENT_SCHEMA = `
|
|
|
101
103
|
END LOOP;
|
|
102
104
|
FOR stale IN
|
|
103
105
|
SELECT conname FROM pg_constraint
|
|
104
|
-
WHERE conrelid = 'live_event_invitations'
|
|
106
|
+
WHERE conrelid = to_regclass('live_event_invitations') AND contype = 'c'
|
|
105
107
|
AND conname <> 'live_event_invitations_role_allowed'
|
|
106
108
|
AND pg_get_constraintdef(oid) LIKE '%moderator%'
|
|
107
109
|
LOOP
|
|
@@ -190,6 +192,45 @@ function text(value, name, limit, required = false) {
|
|
|
190
192
|
throw new LiveEventError(`${name} is too long`, 422);
|
|
191
193
|
return cleaned;
|
|
192
194
|
}
|
|
195
|
+
function classroomSettings(input, current = {}) {
|
|
196
|
+
const settings = {};
|
|
197
|
+
for (const key of ["hostName", "homepageUrl", "avatarUrl", "broadcastUrl"]) {
|
|
198
|
+
const value = input[key] === undefined ? current[key] ?? "" : text(input[key], key, key === "hostName" ? 100 : 2048);
|
|
199
|
+
if (!value)
|
|
200
|
+
continue;
|
|
201
|
+
if (key === "hostName")
|
|
202
|
+
settings[key] = value;
|
|
203
|
+
else if (key === "broadcastUrl") {
|
|
204
|
+
const broadcast = classroomBroadcast(value);
|
|
205
|
+
if (!broadcast)
|
|
206
|
+
throw new LiveEventError("Use a Pairux live/join link or a Nixamp Share link with a stream selected. Admin links cannot be shared.", 422);
|
|
207
|
+
settings[key] = broadcast.url;
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
const safe = publicWebUrl(value);
|
|
211
|
+
if (!safe)
|
|
212
|
+
throw new LiveEventError(`${key} must be an HTTPS link without credentials`, 422);
|
|
213
|
+
settings[key] = safe;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const recurrence = input.recurrence === undefined ? current.recurrence ?? "none" : input.recurrence;
|
|
217
|
+
if (recurrence !== "none" && recurrence !== "daily" && recurrence !== "weekly") {
|
|
218
|
+
throw new LiveEventError("recurrence must be none, daily, or weekly", 422);
|
|
219
|
+
}
|
|
220
|
+
if (recurrence !== "none")
|
|
221
|
+
settings.recurrence = recurrence;
|
|
222
|
+
return settings;
|
|
223
|
+
}
|
|
224
|
+
function classroomTimezone(value) {
|
|
225
|
+
const zone = text(value, "timezone", 100, true);
|
|
226
|
+
try {
|
|
227
|
+
new Intl.DateTimeFormat("en", { timeZone: zone }).format();
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
throw new LiveEventError("timezone must be a valid time zone", 422);
|
|
231
|
+
}
|
|
232
|
+
return zone;
|
|
233
|
+
}
|
|
193
234
|
function timestamp(value, name) {
|
|
194
235
|
if (value === undefined || value === null || value === "")
|
|
195
236
|
return null;
|
|
@@ -271,6 +312,7 @@ export function payToAddress(value, name = "payTo") {
|
|
|
271
312
|
}
|
|
272
313
|
function eventFrom(row) {
|
|
273
314
|
return {
|
|
315
|
+
...((row["classroom"] && typeof row["classroom"] === "object") ? row["classroom"] : {}),
|
|
274
316
|
id: String(row["id"] ?? ""),
|
|
275
317
|
slug: String(row["slug"] ?? ""),
|
|
276
318
|
ownerId: String(row["owner_id"] ?? ""),
|
|
@@ -432,6 +474,9 @@ export class LiveEvents {
|
|
|
432
474
|
if (ticketPriceCents > 0 && !payTo) {
|
|
433
475
|
throw new LiveEventError("a ticketed event needs a payTo address", 422);
|
|
434
476
|
}
|
|
477
|
+
const classroom = classroomSettings(input);
|
|
478
|
+
if (classroom.recurrence && !startsAt)
|
|
479
|
+
throw new LiveEventError("a recurring class needs a start time", 422);
|
|
435
480
|
const baseSlug = eventSlug(title);
|
|
436
481
|
const values = [
|
|
437
482
|
id,
|
|
@@ -448,7 +493,7 @@ export class LiveEvents {
|
|
|
448
493
|
payTo,
|
|
449
494
|
startsAt,
|
|
450
495
|
endsAt,
|
|
451
|
-
|
|
496
|
+
classroomTimezone(input.timezone ?? "UTC"),
|
|
452
497
|
optionalInteger(input.expectedDurationMinutes, "expectedDurationMinutes"),
|
|
453
498
|
startsAt ? "scheduled" : "draft",
|
|
454
499
|
enumValue(input.visibility, LIVE_EVENT_VISIBILITIES, "public", "visibility"),
|
|
@@ -457,6 +502,7 @@ export class LiveEvents {
|
|
|
457
502
|
boolean(input.handRaiseEnabled, true, "handRaiseEnabled"),
|
|
458
503
|
boolean(input.recordingEnabled, false, "recordingEnabled"),
|
|
459
504
|
text(input.layoutId, "layoutId", 100),
|
|
505
|
+
JSON.stringify(classroom),
|
|
460
506
|
];
|
|
461
507
|
await this.ensure();
|
|
462
508
|
let rows;
|
|
@@ -466,10 +512,10 @@ export class LiveEvents {
|
|
|
466
512
|
ticket_price_cents, ticket_currency, ticket_minutes, pay_to,
|
|
467
513
|
starts_at, ends_at, timezone,
|
|
468
514
|
expected_duration_minutes, status, visibility, room_id, chat_enabled,
|
|
469
|
-
hand_raise_enabled, recording_enabled, layout_id
|
|
515
|
+
hand_raise_enabled, recording_enabled, layout_id, classroom
|
|
470
516
|
) VALUES (
|
|
471
517
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULLIF($12, ''), $13, $14, $15,
|
|
472
|
-
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, '')
|
|
518
|
+
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, ''), $24::jsonb
|
|
473
519
|
) RETURNING *`, values));
|
|
474
520
|
}
|
|
475
521
|
catch (error) {
|
|
@@ -481,10 +527,10 @@ export class LiveEvents {
|
|
|
481
527
|
ticket_price_cents, ticket_currency, ticket_minutes, pay_to,
|
|
482
528
|
starts_at, ends_at, timezone,
|
|
483
529
|
expected_duration_minutes, status, visibility, room_id, chat_enabled,
|
|
484
|
-
hand_raise_enabled, recording_enabled, layout_id
|
|
530
|
+
hand_raise_enabled, recording_enabled, layout_id, classroom
|
|
485
531
|
) VALUES (
|
|
486
532
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NULLIF($12, ''), $13, $14, $15,
|
|
487
|
-
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, '')
|
|
533
|
+
$16, $17, $18, $19, $20, $21, $22, NULLIF($23, ''), $24::jsonb
|
|
488
534
|
) RETURNING *`, values));
|
|
489
535
|
}
|
|
490
536
|
const row = rows[0];
|
|
@@ -595,9 +641,9 @@ export class LiveEvents {
|
|
|
595
641
|
throw new LiveEventError("the event changed; reload it and try again", 409);
|
|
596
642
|
}
|
|
597
643
|
const title = input.title === undefined ? current.title : text(input.title, "title", 160, true);
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
644
|
+
let startsAt = input.startsAt === undefined ? current.startsAt ?? null : timestamp(input.startsAt, "startsAt");
|
|
645
|
+
let endsAt = input.endsAt === undefined ? current.endsAt ?? null : timestamp(input.endsAt, "endsAt");
|
|
646
|
+
let doorsOpenAt = input.doorsOpenAt === undefined
|
|
601
647
|
? current.doorsOpenAt ?? null
|
|
602
648
|
: timestamp(input.doorsOpenAt, "doorsOpenAt");
|
|
603
649
|
if (startsAt && endsAt && endsAt <= startsAt) {
|
|
@@ -611,13 +657,41 @@ export class LiveEvents {
|
|
|
611
657
|
if (ticketPriceCents > 0 && !payTo) {
|
|
612
658
|
throw new LiveEventError("a ticketed event needs a payTo address", 422);
|
|
613
659
|
}
|
|
614
|
-
|
|
660
|
+
let nextStatus = enumValue(input.status, LIVE_EVENT_STATUSES, current.status, "status");
|
|
615
661
|
if (!canTransition(current.status, nextStatus)) {
|
|
616
662
|
throw new LiveEventError(`an event cannot move from ${current.status} to ${nextStatus}`, 409);
|
|
617
663
|
}
|
|
618
664
|
if (nextStatus === "scheduled" && !startsAt) {
|
|
619
665
|
throw new LiveEventError("a scheduled event needs startsAt", 422);
|
|
620
666
|
}
|
|
667
|
+
const classroom = classroomSettings(input, current);
|
|
668
|
+
const timezone = input.timezone === undefined ? current.timezone : classroomTimezone(input.timezone);
|
|
669
|
+
if (classroom.recurrence && !startsAt)
|
|
670
|
+
throw new LiveEventError("a recurring class needs a start time", 422);
|
|
671
|
+
let recurringNext = false;
|
|
672
|
+
if (nextStatus === "ended" && classroom.recurrence && startsAt) {
|
|
673
|
+
// Calendar arithmetic in Postgres preserves the host's local time over DST.
|
|
674
|
+
// Consider only the next three candidates, even if many sessions were missed.
|
|
675
|
+
const days = classroom.recurrence === "daily" ? 1 : 7;
|
|
676
|
+
const next = await this.db.query(`WITH anchor AS (
|
|
677
|
+
SELECT $1::timestamptz AT TIME ZONE $2 AS local_start
|
|
678
|
+
), candidates AS (
|
|
679
|
+
SELECT (local_start + n * $3::int * interval '1 day') AT TIME ZONE $2 AS starts_at
|
|
680
|
+
FROM anchor CROSS JOIN LATERAL generate_series(
|
|
681
|
+
GREATEST(1, ((now() AT TIME ZONE $2)::date - local_start::date) / $3::int),
|
|
682
|
+
GREATEST(1, ((now() AT TIME ZONE $2)::date - local_start::date) / $3::int) + 2
|
|
683
|
+
) n
|
|
684
|
+
) SELECT starts_at FROM candidates
|
|
685
|
+
WHERE starts_at > GREATEST(now(), $1::timestamptz) ORDER BY starts_at LIMIT 1`, [startsAt, timezone, days]);
|
|
686
|
+
const nextStart = iso(next.rows[0]?.["starts_at"]);
|
|
687
|
+
if (!nextStart)
|
|
688
|
+
throw new LiveEventError("could not schedule the next class", 503);
|
|
689
|
+
startsAt = nextStart;
|
|
690
|
+
endsAt = null;
|
|
691
|
+
doorsOpenAt = null;
|
|
692
|
+
nextStatus = "scheduled";
|
|
693
|
+
recurringNext = true;
|
|
694
|
+
}
|
|
621
695
|
const { rows } = await this.db.query(`UPDATE live_events SET
|
|
622
696
|
title = $4,
|
|
623
697
|
description = $5,
|
|
@@ -639,6 +713,7 @@ export class LiveEvents {
|
|
|
639
713
|
ticket_currency = $21,
|
|
640
714
|
ticket_minutes = $22,
|
|
641
715
|
pay_to = NULLIF($23, ''),
|
|
716
|
+
classroom = $24::jsonb,
|
|
642
717
|
version = version + 1,
|
|
643
718
|
updated_at = now()
|
|
644
719
|
WHERE (id = $1 OR slug = $1) AND owner_id = $2 AND version = $3
|
|
@@ -651,7 +726,7 @@ export class LiveEvents {
|
|
|
651
726
|
input.topic === undefined ? current.topic ?? "" : text(input.topic, "topic", 100),
|
|
652
727
|
startsAt,
|
|
653
728
|
endsAt,
|
|
654
|
-
|
|
729
|
+
timezone,
|
|
655
730
|
input.expectedDurationMinutes === undefined
|
|
656
731
|
? current.expectedDurationMinutes ?? null
|
|
657
732
|
: optionalInteger(input.expectedDurationMinutes, "expectedDurationMinutes"),
|
|
@@ -660,7 +735,7 @@ export class LiveEvents {
|
|
|
660
735
|
boolean(input.chatEnabled, current.chatEnabled, "chatEnabled"),
|
|
661
736
|
boolean(input.handRaiseEnabled, current.handRaiseEnabled, "handRaiseEnabled"),
|
|
662
737
|
boolean(input.recordingEnabled, current.recordingEnabled, "recordingEnabled"),
|
|
663
|
-
input.recordingId === undefined ? current.recordingId ?? null : text(input.recordingId, "recordingId", 160) || null,
|
|
738
|
+
recurringNext ? null : input.recordingId === undefined ? current.recordingId ?? null : text(input.recordingId, "recordingId", 160) || null,
|
|
664
739
|
input.layoutId === undefined ? current.layoutId ?? null : text(input.layoutId, "layoutId", 100) || null,
|
|
665
740
|
enumValue(input.kind, LIVE_EVENT_KINDS, current.kind, "kind"),
|
|
666
741
|
doorsOpenAt,
|
|
@@ -670,6 +745,7 @@ export class LiveEvents {
|
|
|
670
745
|
: text(input.ticketCurrency, "ticketCurrency", 8, true).toUpperCase(),
|
|
671
746
|
minutes(input.ticketMinutes, current.ticketMinutes, "ticketMinutes"),
|
|
672
747
|
payTo,
|
|
748
|
+
JSON.stringify(classroom),
|
|
673
749
|
]);
|
|
674
750
|
const row = rows[0];
|
|
675
751
|
if (!row)
|
|
@@ -49,6 +49,8 @@ export declare class Interpreter {
|
|
|
49
49
|
private controller;
|
|
50
50
|
private translating;
|
|
51
51
|
private translationController;
|
|
52
|
+
private recognitionFailures;
|
|
53
|
+
private translationFailures;
|
|
52
54
|
private translations;
|
|
53
55
|
constructor(options: {
|
|
54
56
|
language: () => string;
|
|
@@ -57,7 +59,7 @@ export declare class Interpreter {
|
|
|
57
59
|
channel: () => string;
|
|
58
60
|
lines: (lines: Caption[]) => void;
|
|
59
61
|
status: (text: string) => void;
|
|
60
|
-
failed: () => void;
|
|
62
|
+
failed: (message: string) => void;
|
|
61
63
|
fetcher?: typeof fetch;
|
|
62
64
|
});
|
|
63
65
|
reset(): void;
|