refactor(vacay, admin, doc-sync): async DB-touching methods; transactions through UnitOfWork

This commit is contained in:
jubnl
2026-09-22 03:29:33 +02:00
parent 567593a259
commit cb177ce655
28 changed files with 1400 additions and 1356 deletions
+5 -5
View File
@@ -75,19 +75,19 @@ export class AdminController {
// ── Users ──
@Get('users')
listUsers() { return { users: this.admin.listUsers() }; }
async listUsers() { return { users: await this.admin.listUsers() }; }
@Post('users')
@HttpCode(201)
async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
const result = ok(this.admin.createUser(body as Parameters<AdminService['createUser']>[0]));
const result = ok(await this.admin.createUser(body as Parameters<AdminService['createUser']>[0]));
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
return { user: result.user };
}
@Put('users/:id')
async updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
const result = ok(this.admin.updateUser(id, body));
const result = ok(await this.admin.updateUser(id, body));
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
return { user: result.user };
@@ -110,7 +110,7 @@ export class AdminController {
@Delete('users/:id/mfa')
async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.resetUserMfa(id, user.id));
const result = ok(await this.admin.resetUserMfa(id, user.id));
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
return { success: true };
}
@@ -280,7 +280,7 @@ export class AdminController {
// ── Addons ──
@Get('addons')
listAddons() { return { addons: this.admin.listAddons() }; }
async listAddons() { return { addons: await this.admin.listAddons() }; }
@Put('addons/:id')
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) {
+11 -9
View File
@@ -22,6 +22,7 @@ import { getPhotoProviderConfig } from '../memories/memories.helpers';
import { validatePassword } from '../common/passwordPolicy';
import { UserCleanupService } from '../auth/user-cleanup.service';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { AddonsService } from '../addons/addons.service';
import { RealtimeService } from '../realtime/realtime.service';
import { PasskeyService } from '../auth/passkey.service';
@@ -76,11 +77,12 @@ export class AdminService {
private readonly notifications: NotificationsService,
private readonly userCleanup: UserCleanupService,
private readonly realtime: RealtimeService,
private readonly uow: UnitOfWork,
) {}
// ── User CRUD ──────────────────────────────────────────────────────────────
listUsers() {
async listUsers() {
// Guests (#1362) are accountless trip participants, not real users — keep them out
// of admin user management entirely.
const users = this.db.all<
@@ -109,7 +111,7 @@ export class AdminService {
}));
}
createUser(data: { username: string; email: string; password: string; role?: string }) {
async createUser(data: { username: string; email: string; password: string; role?: string }) {
const username = data.username?.trim();
const email = data.email?.trim();
const password = data.password?.trim();
@@ -151,7 +153,7 @@ export class AdminService {
};
}
updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) {
async updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) {
const username = typeof data.username === 'string' ? data.username.trim() : data.username;
const email = typeof data.email === 'string' ? data.email.trim() : data.email;
const { role, password } = data;
@@ -200,7 +202,7 @@ export class AdminService {
// (changePassword, resetPassword) have always done this; this one had not.
const newPv = password ? ((user as User & { password_version?: number }).password_version ?? 0) + 1 : null;
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run(
`
UPDATE users SET
@@ -276,7 +278,7 @@ export class AdminService {
* making that reachable from here would turn a stolen admin session into a
* way to strip the second factor off the very account it came from.
*/
resetUserMfa(id: string, actingUserId: number): { error?: string; status?: number; success?: boolean; email?: string } {
async resetUserMfa(id: string, actingUserId: number): Promise<{ error?: string; status?: number; success?: boolean; email?: string }> {
const targetId = Number(id);
if (targetId === actingUserId) {
return { error: 'Use Settings to change your own two-factor setup', status: 400 };
@@ -299,7 +301,7 @@ export class AdminService {
// ── Stats ──────────────────────────────────────────────────────────────────
getStats() {
async getStats() {
const totalUsers = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
const totalTrips = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM trips')!.count;
const totalPlaces = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM places')!.count;
@@ -327,7 +329,7 @@ export class AdminService {
// ── Audit Log ──────────────────────────────────────────────────────────────
getAuditLog(query: { limit?: string; offset?: string }) {
async getAuditLog(query: { limit?: string; offset?: string }) {
const limitRaw = Number.parseInt(String(query.limit || '100'), 10);
const offsetRaw = Number.parseInt(String(query.offset || '0'), 10);
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
@@ -538,7 +540,7 @@ export class AdminService {
// ── Addons ─────────────────────────────────────────────────────────────────
listAddons() {
async listAddons() {
const addons = this.db
.all<Addon>('SELECT * FROM addons ORDER BY sort_order, id')
// Hidden rather than shown-and-refused, because a toggle that answers 403
@@ -693,7 +695,7 @@ export class AdminService {
return { error: 'Enable the Documents addon first', status: 409 };
}
this.db.transaction(() => {
await this.uow.transactional(async () => {
if (addon) {
if (data.enabled !== undefined) {
this.db.run('UPDATE addons SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { DOCSYNC_SECRET_MASK, type DocsyncConnectionInput, type DocsyncLinkInput } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { checkSsrf } from '../../utils/ssrfGuard';
import { DocumentProviderRegistry } from './document-provider.registry';
import type { DocumentConnectionRef, DocumentScopeRef, DocResult } from './document-provider';
@@ -86,19 +87,20 @@ export class DocSyncConfigService {
constructor(
private readonly db: DatabaseService,
private readonly registry: DocumentProviderRegistry,
private readonly uow: UnitOfWork,
) {}
// ── Provider metadata ──────────────────────────────────────────────────────
/** Only providers the instance admin switched on may be configured. */
enabledProviderIds(): string[] {
async enabledProviderIds(): Promise<string[]> {
return this.db.connection
.prepare('SELECT id FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
.all()
.map((r) => (r as { id: string }).id);
}
providerFields(providerId: string): ProviderFieldRow[] {
async providerFields(providerId: string): Promise<ProviderFieldRow[]> {
// Every column the form needs, not just the three the secret bookkeeping
// uses: the client renders the field from this row, and a missing `label`
// turns into a literal "docsync.undefined" on screen.
@@ -110,8 +112,8 @@ export class DocSyncConfigService {
.all(providerId) as ProviderFieldRow[];
}
private secretKeys(providerId: string): string[] {
return this.providerFields(providerId).filter((f) => f.secret === 1).map((f) => f.field_key);
private async secretKeys(providerId: string): Promise<string[]> {
return (await this.providerFields(providerId)).filter((f) => f.secret === 1).map((f) => f.field_key);
}
/**
@@ -119,7 +121,7 @@ export class DocSyncConfigService {
* admin switching its provider off, and the client only gets names for the
* providers that are still on, so without this it would show the raw id.
*/
private providerName(providerId: string): string {
private async providerName(providerId: string): Promise<string> {
const row = this.db.connection
.prepare('SELECT name FROM document_providers WHERE id = ?')
.get(providerId) as { name: string } | undefined;
@@ -128,11 +130,11 @@ export class DocSyncConfigService {
// ── Connections ────────────────────────────────────────────────────────────
getConnection(id: number): ConnectionRow | undefined {
async getConnection(id: number): Promise<ConnectionRow | undefined> {
return this.db.connection.prepare('SELECT * FROM document_connections WHERE id = ?').get(id) as ConnectionRow | undefined;
}
listConnections(tripId: number): ConnectionRow[] {
async listConnections(tripId: number): Promise<ConnectionRow[]> {
return this.db.connection
.prepare('SELECT * FROM document_connections WHERE trip_id = ? ORDER BY id')
.all(tripId) as ConnectionRow[];
@@ -183,9 +185,9 @@ export class DocSyncConfigService {
* caller's ref: a sync run holds its ref for minutes, and writing that
* snapshot back would undo a password the owner changed in the meantime.
*/
saveEarnedSecret(connectionId: number, key: string, value: string | null): void {
this.db.transaction(() => {
const row = this.getConnection(connectionId);
async saveEarnedSecret(connectionId: number, key: string, value: string | null): Promise<void> {
await this.uow.transactional(async () => {
const row = await this.getConnection(connectionId);
if (!row) return;
const secrets = decryptSecrets(row.secrets);
if (value === null) delete secrets[key];
@@ -197,7 +199,7 @@ export class DocSyncConfigService {
}
/** What a client may see: no secret values, only whether each one is set. */
publicConnection(row: ConnectionRow): Record<string, unknown> {
async publicConnection(row: ConnectionRow): Promise<Record<string, unknown>> {
const ref = this.toRef(row);
return {
id: row.id,
@@ -206,7 +208,7 @@ export class DocSyncConfigService {
ownerUserId: row.owner_user_id,
baseUrl: row.base_url,
settings: ref.settings,
secrets: maskSecrets(ref.secrets, this.secretKeys(row.provider_id)),
secrets: maskSecrets(ref.secrets, await this.secretKeys(row.provider_id)),
allowInsecureTls: row.allow_insecure_tls === 1,
capabilities: row.capabilities ? safeParse(row.capabilities) : null,
lastProbeAt: row.last_probe_at,
@@ -266,7 +268,7 @@ export class DocSyncConfigService {
const urlCheck = await this.validateBaseUrl(input.baseUrl);
if (docFailed(urlCheck)) return { success: false, error: urlCheck.error };
const fields = this.providerFields(input.providerId);
const fields = await this.providerFields(input.providerId);
const secretKeys = fields.filter((f) => f.secret === 1).map((f) => f.field_key);
const plainKeys = fields.filter((f) => f.secret !== 1).map((f) => f.field_key);
@@ -316,7 +318,7 @@ export class DocSyncConfigService {
const settingsJson = JSON.stringify(settings);
const insecure = input.allowInsecureTls ? 1 : 0;
const row = this.db.transaction(() => {
const row = await this.uow.transactional(async () => {
if (existing) {
this.db.connection
.prepare(
@@ -326,7 +328,7 @@ export class DocSyncConfigService {
WHERE id = ?`,
)
.run(urlCheck.data.url, encrypted, settingsJson, insecure, ownerId, existing.id);
return this.getConnection(existing.id) as ConnectionRow;
return (await this.getConnection(existing.id)) as ConnectionRow;
}
const info = this.db.connection
.prepare(
@@ -334,13 +336,13 @@ export class DocSyncConfigService {
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(tripId, input.providerId, userId, urlCheck.data.url, encrypted, settingsJson, insecure);
return this.getConnection(Number(info.lastInsertRowid)) as ConnectionRow;
return (await this.getConnection(Number(info.lastInsertRowid))) as ConnectionRow;
});
return { success: true, data: row };
}
recordProbe(connectionId: number, state: 'ok' | 'failed', error: string | null, capabilities: unknown): void {
async recordProbe(connectionId: number, state: 'ok' | 'failed', error: string | null, capabilities: unknown): Promise<void> {
this.db.connection
.prepare(
`UPDATE document_connections
@@ -356,23 +358,23 @@ export class DocSyncConfigService {
* more: the same promise Dawarich's disconnect makes, and the only version
* of this action that is safe to offer without a confirmation dialog.
*/
deleteConnection(id: number): void {
async deleteConnection(id: number): Promise<void> {
this.db.connection.prepare('DELETE FROM document_connections WHERE id = ?').run(id);
}
// ── Trip bindings ──────────────────────────────────────────────────────────
listLinks(tripId: number): LinkRow[] {
async listLinks(tripId: number): Promise<LinkRow[]> {
return this.db.connection
.prepare('SELECT * FROM trip_document_links WHERE trip_id = ? ORDER BY id')
.all(tripId) as LinkRow[];
}
getLink(id: number): LinkRow | undefined {
async getLink(id: number): Promise<LinkRow | undefined> {
return this.db.connection.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id) as LinkRow | undefined;
}
getLinkByToken(token: string): LinkRow | undefined {
async getLinkByToken(token: string): Promise<LinkRow | undefined> {
return this.db.connection
.prepare('SELECT * FROM trip_document_links WHERE webhook_token = ?')
.get(token) as LinkRow | undefined;
@@ -397,8 +399,8 @@ export class DocSyncConfigService {
* still goes through the connection's own credential, so it cannot open
* anything that credential could not open anyway.
*/
createLink(tripId: number, userId: number, input: DocsyncLinkInput): DocResult<LinkRow> {
const conn = this.getConnection(input.connectionId);
async createLink(tripId: number, userId: number, input: DocsyncLinkInput): Promise<DocResult<LinkRow>> {
const conn = await this.getConnection(input.connectionId);
if (!conn || conn.trip_id !== tripId) {
return { success: false, error: { code: 'not_found', detail: 'connection not found for this trip' } };
}
@@ -436,18 +438,18 @@ export class DocSyncConfigService {
String(encryptSecrets({ webhook: secret })),
userId,
);
return { success: true, data: this.getLink(Number(info.lastInsertRowid)) as LinkRow };
return { success: true, data: (await this.getLink(Number(info.lastInsertRowid))) as LinkRow };
}
updateLink(id: number, patch: Partial<DocsyncLinkInput>): LinkRow | undefined {
async updateLink(id: number, patch: Partial<DocsyncLinkInput>): Promise<LinkRow | undefined> {
const sets: string[] = [];
const values: unknown[] = [];
// Sync automatically is the way back for an orphaned binding, but only once
// the person whose credential it runs under is on the trip again. Until
// then the switch stays off rather than handing that credential back.
const current = patch.syncEnabled === true ? this.getLink(id) : undefined;
const current = patch.syncEnabled === true ? await this.getLink(id) : undefined;
if (current?.last_sync_state === 'orphaned') {
if (this.ownerLeft(current.connection_id)) patch = { ...patch, syncEnabled: undefined };
if (await this.ownerLeft(current.connection_id)) patch = { ...patch, syncEnabled: undefined };
else sets.push("last_sync_state = 'never'");
}
if (patch.direction !== undefined) { sets.push('direction = ?'); values.push(patch.direction); }
@@ -455,10 +457,10 @@ export class DocSyncConfigService {
if (patch.conflictPolicy !== undefined) { sets.push('conflict_policy = ?'); values.push(patch.conflictPolicy); }
if (patch.syncEnabled !== undefined) { sets.push('sync_enabled = ?'); values.push(patch.syncEnabled ? 1 : 0); }
if (patch.remoteLabel !== undefined) { sets.push('remote_label = ?'); values.push(patch.remoteLabel); }
if (sets.length === 0) return this.getLink(id);
if (sets.length === 0) return await this.getLink(id);
sets.push('updated_at = CURRENT_TIMESTAMP');
this.db.connection.prepare(`UPDATE trip_document_links SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
return this.getLink(id);
return await this.getLink(id);
}
/**
@@ -466,8 +468,8 @@ export class DocSyncConfigService {
* document on either side. A user who wants that does it deliberately, in
* the system that holds the file.
*/
deleteLink(id: number): void {
this.db.transaction(() => {
async deleteLink(id: number): Promise<void> {
await this.uow.transactional(async () => {
this.db.connection.prepare('DELETE FROM document_sync_items WHERE link_id = ?').run(id);
this.db.connection.prepare('DELETE FROM trip_document_links WHERE id = ?').run(id);
});
@@ -478,13 +480,13 @@ export class DocSyncConfigService {
}
/** The masked view a client gets, with the webhook URL it may need to paste. */
publicLink(link: LinkRow, webhookBaseUrl: string | null): Record<string, unknown> {
async publicLink(link: LinkRow, webhookBaseUrl: string | null): Promise<Record<string, unknown>> {
return {
id: link.id,
tripId: link.trip_id,
connectionId: link.connection_id,
providerId: link.provider_id,
providerName: this.providerName(link.provider_id),
providerName: await this.providerName(link.provider_id),
scopeKey: link.remote_scope_key,
remoteRootId: link.remote_root_id,
remoteRootPath: link.remote_root_path,
@@ -501,7 +503,7 @@ export class DocSyncConfigService {
// subscribe on its own (Papra, and Nextcloud without admin rights). Not
// for a provider that takes no webhook at all: an address with nowhere
// to paste it only promises what the timer delivers anyway.
webhookUrl: webhookBaseUrl && link.webhook_token && this.takesWebhook(link)
webhookUrl: webhookBaseUrl && link.webhook_token && (await this.takesWebhook(link))
? `${webhookBaseUrl}/api/docsync/webhook/${link.webhook_token}`
: null,
webhookSecret: link.webhook_secret ? DOCSYNC_SECRET_MASK : null,
@@ -513,8 +515,8 @@ export class DocSyncConfigService {
* recorded it. Unknown counts as yes: a connection that was never probed
* still gets the address, and a stale answer costs nothing but a line.
*/
private takesWebhook(link: LinkRow): boolean {
const recorded = this.getConnection(link.connection_id)?.capabilities;
private async takesWebhook(link: LinkRow): Promise<boolean> {
const recorded = (await this.getConnection(link.connection_id))?.capabilities;
const caps = recorded ? safeParse(recorded) : null;
return !(caps && typeof caps === 'object' && (caps as { push?: unknown }).push === 'none');
}
@@ -525,11 +527,11 @@ export class DocSyncConfigService {
* marked; every path that starts a run asks here rather than reading the
* mark alone.
*/
isOrphaned(link: LinkRow): boolean {
return link.last_sync_state === 'orphaned' || this.ownerLeft(link.connection_id);
async isOrphaned(link: LinkRow): Promise<boolean> {
return link.last_sync_state === 'orphaned' || (await this.ownerLeft(link.connection_id));
}
private ownerLeft(connectionId: number): boolean {
private async ownerLeft(connectionId: number): Promise<boolean> {
return !!this.db.connection
.prepare(
`SELECT 1 FROM document_connections c
@@ -550,7 +552,7 @@ export class DocSyncConfigService {
* and a binding that keeps using an ex-member's token is the kind of thing
* nobody notices until it is a complaint.
*/
markOrphanedLinks(): number {
async markOrphanedLinks(): Promise<number> {
const info = this.db.connection
.prepare(
`UPDATE trip_document_links
@@ -74,7 +74,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
@Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.')
@HttpCode(200)
async nudge(@Param('token') token: string, @Req() req: Request) {
const link = this.config.getLinkByToken(token);
const link = await this.config.getLinkByToken(token);
// Always 200, even for an unknown token: a 404 here would let anyone probe
// which tokens exist, and a provider that gets an error will retry anyway.
if (!link || link.sync_enabled !== 1) return { received: true };
@@ -115,7 +115,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
const timer = setTimeout(() => {
void (async () => {
this.pending.delete(linkId);
const fresh = reload();
const fresh = await reload();
if (!fresh || fresh.sync_enabled !== 1) return;
if (!(await this.syncIsOn(fresh))) return;
void this.sync.syncLink(fresh).then((res) => {
+51 -47
View File
@@ -98,7 +98,7 @@ export class DocSyncController {
* The 403 still says "owner": an admin never sees it, and to everybody who
* does, the owner is the person to ask.
*/
private assertCanManage(tripId: string, user: User): void {
private async assertCanManage(tripId: string, user: User): Promise<void> {
if (user.role === 'admin') return;
const trip = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
if (!trip) throw new HttpException('Trip not found', 404);
@@ -111,17 +111,19 @@ export class DocSyncController {
/** Which providers this instance has switched on, with their form fields. */
@Get('providers')
providers() {
async providers() {
const rows = this.db.connection
.prepare('SELECT id, name, description, icon FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
.all() as Array<{ id: string; name: string; description: string | null; icon: string }>;
return rows.map((p) => ({
...p,
// A provider row with no registered adapter would render a form that
// cannot work, so it is reported rather than hidden.
available: !!this.registry.get(p.id),
fields: this.config.providerFields(p.id).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })),
}));
return await Promise.all(
rows.map(async (p) => ({
...p,
// A provider row with no registered adapter would render a form that
// cannot work, so it is reported rather than hidden.
available: !!this.registry.get(p.id),
fields: (await this.config.providerFields(p.id)).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })),
})),
);
}
@Get('status')
@@ -132,8 +134,9 @@ export class DocSyncController {
// ── Connections ────────────────────────────────────────────────────────────
@Get('connections')
listConnections(@Param('tripId') tripId: string) {
return this.config.listConnections(Number(tripId)).map((c) => this.config.publicConnection(c));
async listConnections(@Param('tripId') tripId: string) {
const rows = await this.config.listConnections(Number(tripId));
return await Promise.all(rows.map((c) => this.config.publicConnection(c)));
}
@Put('connections')
@@ -143,8 +146,8 @@ export class DocSyncController {
@CurrentUser() user: User,
@Body() body: DocsyncConnectionDto,
) {
this.assertCanManage(tripId, user);
if (!this.config.enabledProviderIds().includes(body.providerId)) {
await this.assertCanManage(tripId, user);
if (!(await this.config.enabledProviderIds()).includes(body.providerId)) {
throw new HttpException(`Provider: "${body.providerId}" is not enabled, contact server administrator`, 400);
}
const res = await this.config.upsertConnection(Number(tripId), Number(user.id), body);
@@ -164,7 +167,7 @@ export class DocSyncController {
@CurrentUser() user: User,
@Body() body: DocsyncConnectionTestDto,
) {
this.assertCanManage(tripId, user);
await this.assertCanManage(tripId, user);
const provider = this.registry.get(body.providerId);
if (!provider) return { connected: false, error: 'unknown_provider' };
@@ -173,9 +176,9 @@ export class DocSyncController {
// Probing with the values on screen, merged over whatever is stored, so a
// user testing an unchanged connection does not have to retype the secret.
const existing = this.config
.listConnections(Number(tripId))
.find((c) => c.provider_id === body.providerId);
const existing = (await this.config.listConnections(Number(tripId))).find(
(c) => c.provider_id === body.providerId,
);
// Only for the address the credential was stored against. Merging it into a
// probe of an arbitrary baseUrl turns this route into a way to have TREK
// post a stored API token at a server of the caller's choosing, which is
@@ -183,7 +186,7 @@ export class DocSyncController {
// reach for. Same host, same scheme, same port, or the caller types it in.
const sameTarget = !!existing && sameOrigin(existing.base_url, urlCheck.data.url);
const stored = sameTarget && existing ? this.config.toRef(existing) : null;
const fields = this.config.providerFields(body.providerId);
const fields = await this.config.providerFields(body.providerId);
const secrets: Record<string, string> = { ...(stored?.secrets ?? {}) };
const settings: Record<string, string> = { ...(stored?.settings ?? {}) };
for (const f of fields) {
@@ -203,7 +206,7 @@ export class DocSyncController {
allowInsecureTls: body.allowInsecureTls,
});
if (docFailed(res)) return { connected: false, error: res.error.code, detail: res.error.detail };
if (existing) this.config.recordProbe(existing.id, 'ok', null, res.data.capabilities);
if (existing) await this.config.recordProbe(existing.id, 'ok', null, res.data.capabilities);
return { connected: true, account: res.data.account, capabilities: res.data.capabilities };
}
@@ -214,15 +217,15 @@ export class DocSyncController {
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
await this.assertCanManage(tripId, user);
const conn = await this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
// Its bindings go with it (ON DELETE CASCADE), so read them first. Every
// subscription TREK registered for one of them is taken down while the
// credential is still here to do it with, as unbinding one does: left
// standing, a Paperless workflow or a Nextcloud listener keeps posting to
// a token that answers nothing, for good.
const unbound = this.config.listLinks(conn.trip_id).filter((l) => l.connection_id === conn.id);
const unbound = (await this.config.listLinks(conn.trip_id)).filter((l) => l.connection_id === conn.id);
const provider = this.registry.get(conn.provider_id);
if (provider?.unregisterWebhook) {
const ref = this.config.toRef(conn);
@@ -230,7 +233,7 @@ export class DocSyncController {
if (link.webhook_subscription_id) await provider.unregisterWebhook(ref, link.webhook_subscription_id);
}
}
this.config.deleteConnection(conn.id);
await this.config.deleteConnection(conn.id);
for (const link of unbound) this.announceBinding(link);
return { success: true };
}
@@ -245,8 +248,8 @@ export class DocSyncController {
@CurrentUser() user: User,
@Query('q') q?: string,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
await this.assertCanManage(tripId, user);
const conn = await this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
const provider = this.registry.get(conn.provider_id);
if (!provider) throw new HttpException('Provider not available', 400);
@@ -262,8 +265,8 @@ export class DocSyncController {
@CurrentUser() user: User,
@Body() body: DocsyncScopeCreateDto,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
await this.assertCanManage(tripId, user);
const conn = await this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
const provider = this.registry.get(conn.provider_id);
if (!provider) throw new HttpException('Provider not available', 400);
@@ -275,9 +278,10 @@ export class DocSyncController {
// ── Bindings ───────────────────────────────────────────────────────────────
@Get('links')
listLinks(@Param('tripId') tripId: string, @Req() req: Request) {
async listLinks(@Param('tripId') tripId: string, @Req() req: Request) {
const base = publicOrigin(req);
return this.config.listLinks(Number(tripId)).map((l) => this.config.publicLink(l, base));
const rows = await this.config.listLinks(Number(tripId));
return await Promise.all(rows.map((l) => this.config.publicLink(l, base)));
}
@Post('links')
@@ -288,8 +292,8 @@ export class DocSyncController {
@Body() body: DocsyncLinkDto,
@Req() req: Request,
) {
this.assertCanManage(tripId, user);
const res = this.config.createLink(Number(tripId), Number(user.id), body);
await this.assertCanManage(tripId, user);
const res = await this.config.createLink(Number(tripId), Number(user.id), body);
if (docFailed(res)) throw new HttpException(res.error.detail || res.error.code, 400);
this.announceBinding(res.data);
@@ -297,7 +301,7 @@ export class DocSyncController {
// not a failure of the binding (polling still carries it), so it is logged
// rather than thrown at the user; the first run that follows would write
// over anything put into the link state.
const conn = this.config.getConnection(res.data.connection_id);
const conn = await this.config.getConnection(res.data.connection_id);
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
if (conn && provider?.registerWebhook) {
const base = publicOrigin(req);
@@ -323,23 +327,23 @@ export class DocSyncController {
// A first run right away, so the user sees something happen instead of
// waiting out a poll interval and wondering whether it worked.
void this.sync.syncLink(res.data, { full: true });
return this.config.publicLink(this.config.getLink(res.data.id) ?? res.data, publicOrigin(req));
return await this.config.publicLink((await this.config.getLink(res.data.id)) ?? res.data, publicOrigin(req));
}
@Patch('links/:linkId')
@HttpCode(200)
updateLink(
async updateLink(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@CurrentUser() user: User,
@Body() body: DocsyncLinkUpdateDto,
@Req() req: Request,
) {
this.assertCanManage(tripId, user);
const link = this.config.getLink(Number(linkId));
await this.assertCanManage(tripId, user);
const link = await this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
const updated = this.config.updateLink(link.id, body);
return this.config.publicLink(updated ?? link, publicOrigin(req));
const updated = await this.config.updateLink(link.id, body);
return await this.config.publicLink(updated ?? link, publicOrigin(req));
}
@Delete('links/:linkId')
@@ -349,16 +353,16 @@ export class DocSyncController {
@Param('linkId') linkId: string,
@CurrentUser() user: User,
) {
this.assertCanManage(tripId, user);
const link = this.config.getLink(Number(linkId));
await this.assertCanManage(tripId, user);
const link = await this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
const conn = this.config.getConnection(link.connection_id);
const conn = await this.config.getConnection(link.connection_id);
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
if (conn && provider?.unregisterWebhook && link.webhook_subscription_id) {
await provider.unregisterWebhook(this.config.toRef(conn), link.webhook_subscription_id);
}
this.config.deleteLink(link.id);
await this.config.deleteLink(link.id);
this.announceBinding(link);
// Unbinding keeps both copies. Nothing is deleted anywhere.
return { success: true, documentsKept: true };
@@ -371,12 +375,12 @@ export class DocSyncController {
@Param('linkId') linkId: string,
@Body() body: DocsyncSyncNowDto,
) {
const link = this.config.getLink(Number(linkId));
const link = await this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
// An orphaned binding stays orphaned: its credential belongs to somebody who
// is no longer on this trip. The run itself would stand down as well; this
// says why, instead of answering with an empty run.
if (this.config.isOrphaned(link)) {
if (await this.config.isOrphaned(link)) {
throw new HttpException({ error: 'This binding lost its owner and has to be reconnected' }, 409);
}
// Refused before the shelved rows are touched, so a binding an admin
@@ -387,14 +391,14 @@ export class DocSyncController {
}
// A person asking for a run is also asking for the rows that gave up to be
// tried once more; the scheduler gets no such reprieve.
this.sync.retryShelvedItems(link.id);
await this.sync.retryShelvedItems(link.id);
return this.sync.syncLink(link, { full: body.full });
}
// ── Documents and conflicts ────────────────────────────────────────────────
@Get('items')
items(@Param('tripId') tripId: string, @Query('state') state?: string) {
async items(@Param('tripId') tripId: string, @Query('state') state?: string) {
const rows = state
? this.db.connection
.prepare(
@@ -421,7 +425,7 @@ export class DocSyncController {
@CurrentUser() user: User,
@Body() body: DocsyncResolveConflictDto,
) {
this.assertCanManage(tripId, user);
await this.assertCanManage(tripId, user);
const ok = await this.sync.resolveConflict(Number(itemId), body.keep, Number(tripId));
if (!ok) throw new HttpException('Item is not in conflict', 400);
return { success: true };
+8 -8
View File
@@ -50,9 +50,9 @@ export class DocSyncJob implements OnApplicationBootstrap {
/** When the last pass started; null until the first one, which is never skipped. */
private lastRunAt: number | null = null;
onApplicationBootstrap(): void {
async onApplicationBootstrap(): Promise<void> {
if (!this.registrar.isEnabled()) return;
logInfo(`Document sync: polling every ${this.intervalSeconds()}s`);
logInfo(`Document sync: polling every ${await this.intervalSeconds()}s`);
this.registrar.register('docsync', '* * * * *', () => this.tick());
}
@@ -64,16 +64,16 @@ export class DocSyncJob implements OnApplicationBootstrap {
* boundaries fall. Comparing against the last run keeps the setting's own
* resolution; the cost of the extra wake-ups is one read of app_settings.
*/
private isDue(now: number): boolean {
private async isDue(now: number): Promise<boolean> {
// Null rather than 0: comparing against the epoch means "due" only once the
// clock has passed the interval since 1970, which is true in production and
// false for any test that picks a small timestamp, a difference that would
// have hidden here rather than in the behaviour it is supposed to describe.
if (this.lastRunAt === null) return true;
return now - this.lastRunAt >= this.intervalSeconds() * 1000;
return now - this.lastRunAt >= (await this.intervalSeconds()) * 1000;
}
private intervalSeconds(): number {
private async intervalSeconds(): Promise<number> {
const raw = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', SETTING_POLL_INTERVAL)?.value;
const parsed = Number.parseInt(raw || '', 10);
if (!Number.isFinite(parsed)) return DEFAULT_POLL_INTERVAL_SECONDS;
@@ -89,15 +89,15 @@ export class DocSyncJob implements OnApplicationBootstrap {
if (killSwitch === 'false') return;
const now = Date.now();
if (!this.isDue(now)) return;
if (!(await this.isDue(now))) return;
this.lastRunAt = now;
// Cheap, and it catches a binding whose owner left the trip through a
// path that has no hook to attach to: a transfer, a direct DB edit.
const orphaned = this.config.markOrphanedLinks();
const orphaned = await this.config.markOrphanedLinks();
if (orphaned > 0) logInfo(`Document sync: ${orphaned} link(s) orphaned, owner no longer on the trip`);
const links = this.sync.dueLinks();
const links = await this.sync.dueLinks();
for (const link of links) {
// One failing provider must not stop the others: an unreachable NAS on
// one trip is not a reason to skip a Paperless binding on another.
+5 -5
View File
@@ -69,9 +69,9 @@ export class DocSyncMcp {
})
async listIssues({ tripId }: { tripId: number }, ctx: McpContext) {
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const links = this.config.listLinks(tripId);
const links = await this.config.listLinks(tripId);
if (links.length === 0) return ok({ configured: false, issues: [] });
return ok({ configured: true, issues: this.sync.issues(tripId) });
return ok({ configured: true, issues: await this.sync.issues(tripId) });
}
@Tool({
@@ -92,7 +92,7 @@ export class DocSyncMcp {
})
async syncNow({ tripId, full }: { tripId: number; full?: boolean }, ctx: McpContext) {
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const links = this.config.listLinks(tripId);
const links = await this.config.listLinks(tripId);
if (links.length === 0) {
return errorResult('This trip is not connected to a document store. Connect one in the trip\'s file manager first.');
}
@@ -101,7 +101,7 @@ export class DocSyncMcp {
// An orphaned binding stays stopped, as it does on the REST route and for
// the scheduler: its credential belongs to somebody who has left the trip,
// and a run would use it anyway.
if (this.config.isOrphaned(link)) {
if (await this.config.isOrphaned(link)) {
results.push({ linkId: link.id, provider: link.provider_id, state: 'orphaned', errorCode: 'orphaned' });
continue;
}
@@ -116,7 +116,7 @@ export class DocSyncMcp {
// that were shelved after too many failures. The REST route does the
// same thing before its run; a tool that skipped it would answer "in
// sync" while leaving them shelved.
this.sync.retryShelvedItems(link.id);
await this.sync.retryShelvedItems(link.id);
results.push({ linkId: link.id, provider: link.provider_id, ...(await this.sync.syncLink(link, { full: full === true })) });
}
if (results.every((r) => r.errorCode === PROVIDER_DISABLED)) {
+43 -43
View File
@@ -109,7 +109,7 @@ export class DocSyncService {
* than stood down per run. It never runs, so it never gets a `last_sync_at`,
* and twenty of them would sort first on every tick and take every slot.
*/
dueLinks(limit = 20): LinkRow[] {
async dueLinks(limit = 20): Promise<LinkRow[]> {
return this.db.connection
.prepare(
`SELECT * FROM trip_document_links
@@ -137,7 +137,7 @@ export class DocSyncService {
*/
async isSwitchedOff(link: Pick<LinkRow, 'provider_id'>): Promise<boolean> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) return true;
return !this.config.enabledProviderIds().includes(link.provider_id);
return !(await this.config.enabledProviderIds()).includes(link.provider_id);
}
/**
@@ -163,7 +163,7 @@ export class DocSyncService {
return await this.runLink(link, opts);
} catch (err) {
this.logger.error(`link ${link.id} failed: ${err instanceof Error ? err.message : String(err)}`);
this.recordLinkFailure(link, 'unknown');
await this.recordLinkFailure(link, 'unknown');
return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'unknown' };
} finally {
this.running.delete(link.id);
@@ -182,17 +182,17 @@ export class DocSyncService {
// An orphaned binding runs for nobody. Its credential belongs to somebody
// who has left the trip, and a webhook or a resolved conflict reach this
// without asking beforehand.
if (this.config.isOrphaned(link)) {
if (await this.config.isOrphaned(link)) {
return { state: 'orphaned', pulled: 0, pushed: 0, conflicts: 0, missing: 0 };
}
const conn = this.config.getConnection(link.connection_id);
const conn = await this.config.getConnection(link.connection_id);
if (!conn) {
this.recordLinkFailure(link, 'not_found');
await this.recordLinkFailure(link, 'not_found');
return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'not_found' };
}
const provider = this.registry.get(link.provider_id);
if (!provider) {
this.recordLinkFailure(link, 'provider_error');
await this.recordLinkFailure(link, 'provider_error');
return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'provider_error' };
}
@@ -204,20 +204,20 @@ export class DocSyncService {
const resolved = await provider.resolveScope(ref, scope);
if (docFailed(resolved)) {
const code = resolved.error.code === 'not_found' || resolved.error.code === 'scope_missing' ? 'scope_missing' : resolved.error.code;
this.recordLinkFailure(link, code, code === 'scope_missing' ? 'scope_lost' : undefined);
await this.recordLinkFailure(link, code, code === 'scope_missing' ? 'scope_lost' : undefined);
return { state: code === 'scope_missing' ? 'scope_lost' : 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: code };
}
// Read before the listing because they decide how much of it is needed: a
// file restored in TREK waits for a listing that shows its copy.
const items = this.loadItems(link.id);
const local = this.loadLocalDocuments(link);
const items = await this.loadItems(link.id);
const local = await this.loadLocalDocuments(link);
if (opts.full || needsFullListing(items, local)) scope.cursor = null;
const listing = await provider.list(ref, scope);
if (docFailed(listing)) {
const state = listing.error.code === 'unauthorized' ? 'needs_reauth' : 'failed';
this.recordLinkFailure(link, listing.error.code, state);
await this.recordLinkFailure(link, listing.error.code, state);
return { state, pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: listing.error.code };
}
@@ -237,7 +237,7 @@ export class DocSyncService {
// Refusing the whole run is the point: the listing is not trustworthy, so
// nothing in it should be acted on, not even the parts that look fine.
this.logger.warn(`link ${link.id}: mass-delete guard tripped (${plan.missingCount} of ${items.length} gone), run abandoned`);
this.recordLinkFailure(link, 'mass_delete_guard', 'partial');
await this.recordLinkFailure(link, 'mass_delete_guard', 'partial');
return { state: 'partial', pulled: 0, pushed: 0, conflicts: 0, missing: plan.missingCount, errorCode: 'mass_delete_guard' };
}
@@ -283,7 +283,7 @@ export class DocSyncService {
* clean.
*/
const cursor = state === 'ok' && !wroteUpstream ? listing.data.cursor : null;
this.recordLinkSuccess(link, cursor, state, softFailure ?? null);
await this.recordLinkSuccess(link, cursor, state, softFailure ?? null);
if (pulled > 0 || pushed > 0) {
this.realtime.broadcast(link.trip_id, 'docsync:changed', { linkId: link.id, pulled, pushed });
}
@@ -468,17 +468,17 @@ export class DocSyncService {
// when it arrives through the store instead.
const allowed = isVideoExtension(path.extname(name)) || isAllowedByOperator(name, await this.allowedTypes.get());
if (isBlockedName(name) || !allowed) {
this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' });
await this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' });
return 'unsupported_type';
}
if (remote.size !== null && remote.size > MAX_FILE_SIZE) {
this.upsertItem(ctx.link, { itemId, remote, state: 'too_large', errorCode: 'too_large' });
await this.upsertItem(ctx.link, { itemId, remote, state: 'too_large', errorCode: 'too_large' });
return 'too_large';
}
const fetched = await ctx.provider.fetch(ctx.ref, ctx.scope, remote.remoteId);
if (docFailed(fetched)) {
this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: fetched.error.code });
await this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: fetched.error.code });
return fetched.error.code;
}
@@ -506,7 +506,7 @@ export class DocSyncService {
} catch (err) {
await fs.promises.rm(tmpPath, { force: true });
const code: DocsyncErrorCode = err instanceof Error && err.message === 'too_large' ? 'too_large' : 'provider_error';
this.upsertItem(ctx.link, { itemId, remote, state: code === 'too_large' ? 'too_large' : 'error', errorCode: code });
await this.upsertItem(ctx.link, { itemId, remote, state: code === 'too_large' ? 'too_large' : 'error', errorCode: code });
return code;
}
@@ -531,7 +531,7 @@ export class DocSyncService {
// here would read it as TREK's own and send the old name back up.
if (superseded && !superseded.deleted_at && pairing?.content_sha256 === sha256) {
await fs.promises.rm(tmpPath, { force: true });
this.upsertItem(ctx.link, {
await this.upsertItem(ctx.link, {
itemId,
remote,
state: 'synced',
@@ -545,7 +545,7 @@ export class DocSyncService {
await this.storage.put('files', storageKey, { tmpPath });
} catch {
await fs.promises.rm(tmpPath, { force: true });
this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: 'provider_error' });
await this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: 'provider_error' });
return 'provider_error';
}
@@ -568,7 +568,7 @@ export class DocSyncService {
{ filename: storageKey, originalname: name, size: bytes, mimetype: remote.mimeType || 'application/octet-stream' },
// Attributed to the person whose connection brought it in, which is the
// only honest answer: nobody in TREK uploaded it.
this.config.getConnection(ctx.link.connection_id)?.owner_user_id ?? 0,
(await this.config.getConnection(ctx.link.connection_id))?.owner_user_id ?? 0,
{
place_id: superseded?.place_id ?? null,
reservation_id: superseded?.reservation_id ?? null,
@@ -587,7 +587,7 @@ export class DocSyncService {
.run(file.id, supersededId);
await this.files.softDeleteFile(supersededId as number);
}
this.upsertItem(ctx.link, {
await this.upsertItem(ctx.link, {
itemId,
remote,
state: 'synced',
@@ -618,11 +618,11 @@ export class DocSyncService {
// Paperless refuses anything outside its parser list with a 400. Checking
// first turns a recurring hard failure into one visible row that says why.
if (caps.acceptedMimeTypes && !caps.acceptedMimeTypes.includes(mime)) {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'rejected_type', errorCode: 'unsupported_type' });
await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'rejected_type', errorCode: 'unsupported_type' });
return 'unsupported_type';
}
if (caps.maxUploadBytes !== null && local.size > caps.maxUploadBytes) {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'too_large', errorCode: 'too_large' });
await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'too_large', errorCode: 'too_large' });
return 'too_large';
}
@@ -644,11 +644,11 @@ export class DocSyncService {
const got = await this.storage.getStream('files', String(file.filename));
stream = got.stream;
} catch {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: 'provider_error' });
await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: 'provider_error' });
return 'provider_error';
}
const uid = this.existingUid(ctx.link.id, itemId) ?? newTrekDocUid();
const uid = (await this.existingUid(ctx.link.id, itemId)) ?? newTrekDocUid();
const res = await ctx.provider.push(ctx.ref, ctx.scope, {
body: stream,
fileName: local.name,
@@ -662,7 +662,7 @@ export class DocSyncService {
});
if (docFailed(res)) {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: res.error.code, trekDocUid: uid });
await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: res.error.code, trekDocUid: uid });
return res.error.code;
}
@@ -672,9 +672,9 @@ export class DocSyncService {
// the unique index on (link_id, remote_id) would abort this run (and every
// run after it) with a constraint error. The upload happened, so nothing is
// lost; what must not happen is the pairing moving off the file that owns it.
const heldBy = this.pairingOwner(ctx.link.id, res.data.remoteId, itemId);
const heldBy = await this.pairingOwner(ctx.link.id, res.data.remoteId, itemId);
if (heldBy !== null) {
this.upsertItem(ctx.link, {
await this.upsertItem(ctx.link, {
itemId,
fileId: local.fileId,
state: 'error',
@@ -684,7 +684,7 @@ export class DocSyncService {
return 'conflict';
}
this.upsertItem(ctx.link, {
await this.upsertItem(ctx.link, {
itemId,
fileId: local.fileId,
state: 'synced',
@@ -718,7 +718,7 @@ export class DocSyncService {
* and the only way to find out is to try again. The scheduler never calls
* this: automatic retries are exactly what the limit exists to stop.
*/
retryShelvedItems(linkId: number): void {
async retryShelvedItems(linkId: number): Promise<void> {
this.db.connection
.prepare(
`UPDATE document_sync_items
@@ -730,7 +730,7 @@ export class DocSyncService {
// ── State loading and writing ──────────────────────────────────────────────
private loadItems(linkId: number): SyncItemState[] {
private async loadItems(linkId: number): Promise<SyncItemState[]> {
const rows = this.db.connection
.prepare(
`SELECT id, file_id, trek_doc_uid, remote_id, remote_version, remote_name, remote_size, remote_modified_at,
@@ -771,7 +771,7 @@ export class DocSyncService {
* Paperless, everything else to Nextcloud": splitting a trip across bindings
* would need a per-document assignment that nothing in the UI offers yet.
*/
private loadLocalDocuments(link: LinkRow): LocalDocument[] {
private async loadLocalDocuments(link: LinkRow): Promise<LocalDocument[]> {
const rows = this.db.connection
.prepare(
`SELECT f.id, f.original_name, f.file_size, f.mime_type, f.deleted_at
@@ -797,7 +797,7 @@ export class DocSyncService {
}
/** The item already paired with this remote document, if it is a different one. */
private pairingOwner(linkId: number, remoteId: string, itemId: number | null): number | null {
private async pairingOwner(linkId: number, remoteId: string, itemId: number | null): Promise<number | null> {
const row = this.db.connection
.prepare('SELECT id FROM document_sync_items WHERE link_id = ? AND remote_id = ?')
.get(linkId, remoteId) as { id?: number } | undefined;
@@ -805,7 +805,7 @@ export class DocSyncService {
return itemId !== null && Number(row.id) === Number(itemId) ? null : Number(row.id);
}
private existingUid(linkId: number, itemId: number | null): string | null {
private async existingUid(linkId: number, itemId: number | null): Promise<string | null> {
if (itemId === null) return null;
const row = this.db.connection
.prepare('SELECT trek_doc_uid FROM document_sync_items WHERE id = ? AND link_id = ?')
@@ -820,7 +820,7 @@ export class DocSyncService {
return hash.digest('hex');
}
private upsertItem(
private async upsertItem(
link: LinkRow,
patch: {
itemId: number | null;
@@ -839,7 +839,7 @@ export class DocSyncService {
pushedSha256?: string;
trekDocUid?: string;
},
): void {
): Promise<void> {
const failed = patch.state === 'error';
/**
* A failed transfer leaves the pairing describing the copy TREK holds.
@@ -946,7 +946,7 @@ export class DocSyncService {
);
}
private recordLinkSuccess(link: LinkRow, cursor: string | null, state: string, errorCode: string | null): void {
private async recordLinkSuccess(link: LinkRow, cursor: string | null, state: string, errorCode: string | null): Promise<void> {
this.db.connection
.prepare(
`UPDATE trip_document_links
@@ -957,7 +957,7 @@ export class DocSyncService {
.run(cursor, state, errorCode, link.id);
}
private recordLinkFailure(link: LinkRow, code: string, state = 'failed'): void {
private async recordLinkFailure(link: LinkRow, code: string, state = 'failed'): Promise<void> {
const failures = link.failure_count + 1;
const wait = backoffSeconds(LINK_BACKOFF_SECONDS, failures);
this.db.connection
@@ -998,7 +998,7 @@ export class DocSyncService {
* next run to sort out rather than the owner's choice being refused.
*/
private async renameRemoteTo(link: LinkRow, remoteId: string, name: string, itemId: number): Promise<void> {
const conn = this.config.getConnection(link.connection_id);
const conn = await this.config.getConnection(link.connection_id);
const provider = this.registry.get(link.provider_id);
if (!conn || !provider) return;
const res = await provider.rename(this.config.toRef(conn), this.config.toScopeRef(link), remoteId, name);
@@ -1017,7 +1017,7 @@ export class DocSyncService {
.get(itemId) as Record<string, unknown> | undefined;
if (!item || item.state !== 'conflict') return false;
if (tripId !== undefined && Number(item.trip_id) !== Number(tripId)) return false;
const link = this.config.getLink(Number(item.link_id));
const link = await this.config.getLink(Number(item.link_id));
if (!link) return false;
/**
@@ -1087,7 +1087,7 @@ export class DocSyncService {
* that vanished upstream. Deliberately not "everything not synced": a row
* waiting for its turn is not a problem anyone should be shown.
*/
issues(tripId: number): Array<Record<string, unknown>> {
async issues(tripId: number): Promise<Array<Record<string, unknown>>> {
return this.db.connection
.prepare(
`SELECT i.id, i.state, i.error_code, i.remote_name, i.remote_missing_at, f.original_name AS file_name
@@ -1103,7 +1103,7 @@ export class DocSyncService {
/** Per-trip view for the UI: what is synced, what needs attention. */
async status(tripId: number): Promise<Record<string, unknown>> {
const links = this.config.listLinks(tripId);
const links = await this.config.listLinks(tripId);
const counts = this.db.connection
.prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state')
.all(tripId) as Array<{ state: string; n: number }>;
@@ -1146,7 +1146,7 @@ export class DocSyncService {
for (const l of links) {
const row = byLink.get(l.id);
linkCards.push({
...this.config.publicLink(l, null),
...(await this.config.publicLink(l, null)),
// Paused rather than failed, so the card can say why nothing moves
// without the binding's own state being touched.
providerOff: await this.isSwitchedOff(l),
@@ -88,7 +88,7 @@ export interface DocumentConnectionRef {
* stored but never changes it: the values on screen may be for another
* account, or never be saved at all.
*/
saveSecret?: (key: string, value: string | null) => void;
saveSecret?: (key: string, value: string | null) => Promise<void>;
}
/** The container at the provider that one trip is bound to. */
@@ -228,7 +228,7 @@ export interface SynologyDriveCreds {
* connection passes one; a probe of form values uses the stored token without
* being able to change it.
*/
saveDeviceToken?: (stored: string | null) => void;
saveDeviceToken?: (stored: string | null) => Promise<void>;
allowInsecureTls: boolean;
}
@@ -554,7 +554,7 @@ export class SynologyDriveClient {
* anything is written, so a change is written once and not on every call of
* the run that made it.
*/
private deviceSlot(creds: SynologyDriveCreds): DeviceTokenSlot | null {
private async deviceSlot(creds: SynologyDriveCreds): Promise<DeviceTokenSlot | null> {
if (creds.connectionId <= 0) return null;
const key = `${creds.connectionId}\u0000${creds.connectionCreatedAt}`;
const account = this.accountDigest(creds);
@@ -563,24 +563,24 @@ export class SynologyDriveClient {
slot = { account, token: deviceTokenFor(creds.storedDeviceToken, account) };
this.deviceTokens.set(key, slot);
}
this.persist(creds, slot);
await this.persist(creds, slot);
return slot;
}
private persist(creds: SynologyDriveCreds, slot: DeviceTokenSlot): void {
private async persist(creds: SynologyDriveCreds, slot: DeviceTokenSlot): Promise<void> {
if (!creds.saveDeviceToken) return;
if (slot.stored === undefined) slot.stored = creds.storedDeviceToken ?? null;
const wanted = slot.token === null ? null : storedDeviceToken(slot.account, slot.token);
if (wanted === slot.stored) return;
creds.saveDeviceToken(wanted);
await creds.saveDeviceToken(wanted);
slot.stored = wanted;
}
private setDeviceToken(creds: SynologyDriveCreds, token: string | null): void {
const slot = this.deviceSlot(creds);
private async setDeviceToken(creds: SynologyDriveCreds, token: string | null): Promise<void> {
const slot = await this.deviceSlot(creds);
if (!slot || slot.token === token) return;
slot.token = token;
this.persist(creds, slot);
await this.persist(creds, slot);
}
private async request(
@@ -691,8 +691,8 @@ export class SynologyDriveClient {
private async session(creds: SynologyDriveCreds): Promise<SynoSession> {
const cached = this.sessions.get(this.sessionKey(creds));
if (cached && Date.now() - cached.createdAt < SESSION_MAX_AGE_MS) {
const slot = this.deviceSlot(creds);
if (slot && slot.token === null && cached.deviceId) this.setDeviceToken(creds, cached.deviceId);
const slot = await this.deviceSlot(creds);
if (slot && slot.token === null && cached.deviceId) await this.setDeviceToken(creds, cached.deviceId);
return cached;
}
return await this.login(creds);
@@ -736,7 +736,7 @@ export class SynologyDriveClient {
throw new SynologyDriveError('provider_error', 'The NAS accepted the login but returned no session id');
}
const issuedDeviceId = isRecord(data) && typeof data.did === 'string' ? data.did : deviceId;
if (issuedDeviceId) this.setDeviceToken(creds, issuedDeviceId);
if (issuedDeviceId) await this.setDeviceToken(creds, issuedDeviceId);
const session: SynoSession = {
sid,
synoToken: isRecord(data) && typeof data.synotoken === 'string' ? data.synotoken : null,
@@ -761,7 +761,7 @@ export class SynologyDriveClient {
* is what the lockout records.
*/
private async authenticate(creds: SynologyDriveCreds): Promise<{ data: unknown; deviceId: string | null }> {
const deviceId = this.deviceSlot(creds)?.token ?? null;
const deviceId = (await this.deviceSlot(creds))?.token ?? null;
try {
return { data: await this.call(creds, this.loginParams(creds, deviceId), { cgi: AUTH_CGI }), deviceId };
} catch (error: unknown) {
@@ -769,7 +769,7 @@ export class SynologyDriveClient {
throw error;
}
// Unless another login replaced it while this one was out.
if (this.deviceSlot(creds)?.token === deviceId) this.setDeviceToken(creds, null);
if ((await this.deviceSlot(creds))?.token === deviceId) await this.setDeviceToken(creds, null);
if (!creds.otpCode) throw error;
return { data: await this.call(creds, this.loginParams(creds, null), { cgi: AUTH_CGI }), deviceId: null };
}
+1 -1
View File
@@ -417,7 +417,7 @@ export class TripsService {
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId);
if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date)
this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
await this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
if (regenerate) {
await this.uow.transactional(async () => {
+67 -67
View File
@@ -60,32 +60,32 @@ export class VacayController {
@Put('plan')
async updatePlan(@CurrentUser() user: User, @Body() body: VacayUpdatePlanDto, @Headers('x-socket-id') socketId?: string) {
const planId = this.vacay.getActivePlanId(user.id);
const planId = await this.vacay.getActivePlanId(user.id);
return this.vacay.updatePlan(planId, body, socketId);
}
@Post('plan/holiday-calendars')
@HttpCode(200)
addHolidayCalendar(
async addHolidayCalendar(
@CurrentUser() user: User,
@Body() body: VacayAddHolidayCalendarDto,
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
const calendar = this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId, body.type);
const planId = await this.vacay.getActivePlanId(user.id);
const calendar = await this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId, body.type);
return { calendar };
}
@Put('plan/holiday-calendars/:id')
updateHolidayCalendar(
async updateHolidayCalendar(
@CurrentUser() user: User,
@Param('id') idParam: string,
@Body() body: VacayUpdateHolidayCalendarDto,
@Headers('x-socket-id') socketId?: string,
) {
const id = Number.parseInt(idParam);
const planId = this.vacay.getActivePlanId(user.id);
const calendar = this.vacay.updateHolidayCalendar(id, planId, body, socketId);
const planId = await this.vacay.getActivePlanId(user.id);
const calendar = await this.vacay.updateHolidayCalendar(id, planId, body, socketId);
if (!calendar) {
throw new HttpException({ error: 'Calendar not found' }, 404);
}
@@ -93,38 +93,38 @@ export class VacayController {
}
@Delete('plan/holiday-calendars/:id')
deleteHolidayCalendar(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
async deleteHolidayCalendar(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
const id = Number.parseInt(idParam);
const planId = this.vacay.getActivePlanId(user.id);
if (!this.vacay.deleteHolidayCalendar(id, planId, socketId)) {
const planId = await this.vacay.getActivePlanId(user.id);
if (!(await this.vacay.deleteHolidayCalendar(id, planId, socketId))) {
throw new HttpException({ error: 'Calendar not found' }, 404);
}
return { success: true };
}
@Put('color')
setColor(
async setColor(
@CurrentUser() user: User,
@Body() body: VacaySetColorDto,
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
const planId = await this.vacay.getActivePlanId(user.id);
const userId = body.target_user_id ? Number.parseInt(String(body.target_user_id)) : user.id;
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === userId)) {
if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === userId)) {
throw new HttpException({ error: 'User not in plan' }, 403);
}
this.vacay.setUserColor(userId, planId, body.color, socketId);
await this.vacay.setUserColor(userId, planId, body.color, socketId);
return { success: true };
}
@Post('invite')
@HttpCode(200)
invite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
async invite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
if (!body.user_id) {
throw new HttpException({ error: 'user_id required' }, 400);
}
const plan = this.vacay.getActivePlan(user.id);
const result = this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number);
const plan = await this.vacay.getActivePlan(user.id);
const result = await this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -133,8 +133,8 @@ export class VacayController {
@Post('invite/accept')
@HttpCode(200)
acceptInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
const result = this.vacay.acceptInvite(user.id, body.plan_id as number, socketId);
async acceptInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
const result = await this.vacay.acceptInvite(user.id, body.plan_id as number, socketId);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -143,71 +143,71 @@ export class VacayController {
@Post('invite/decline')
@HttpCode(200)
declineInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
this.vacay.declineInvite(user.id, body.plan_id as number, socketId);
async declineInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
await this.vacay.declineInvite(user.id, body.plan_id as number, socketId);
return { success: true };
}
@Post('invite/cancel')
@HttpCode(200)
cancelInvite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
const plan = this.vacay.getActivePlan(user.id);
this.vacay.cancelInvite(plan.id, body.user_id as number);
async cancelInvite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
const plan = await this.vacay.getActivePlan(user.id);
await this.vacay.cancelInvite(plan.id, body.user_id as number);
return { success: true };
}
@Post('dissolve')
@HttpCode(200)
dissolve(@CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) {
this.vacay.dissolvePlan(user.id, socketId);
async dissolve(@CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) {
await this.vacay.dissolvePlan(user.id, socketId);
return { success: true };
}
@Get('available-users')
availableUsers(@CurrentUser() user: User) {
const planId = this.vacay.getActivePlanId(user.id);
return { users: this.vacay.getAvailableUsers(user.id, planId) };
async availableUsers(@CurrentUser() user: User) {
const planId = await this.vacay.getActivePlanId(user.id);
return { users: await this.vacay.getAvailableUsers(user.id, planId) };
}
@Get('years')
years(@CurrentUser() user: User) {
const planId = this.vacay.getActivePlanId(user.id);
return { years: this.vacay.listYears(planId) };
async years(@CurrentUser() user: User) {
const planId = await this.vacay.getActivePlanId(user.id);
return { years: await this.vacay.listYears(planId) };
}
@Post('years')
@HttpCode(200)
addYear(@CurrentUser() user: User, @Body() body: VacayAddYearDto, @Headers('x-socket-id') socketId?: string) {
async addYear(@CurrentUser() user: User, @Body() body: VacayAddYearDto, @Headers('x-socket-id') socketId?: string) {
if (!body.year) {
throw new HttpException({ error: 'Year required' }, 400);
}
const planId = this.vacay.getActivePlanId(user.id);
return { years: this.vacay.addYear(planId, body.year as number, socketId) };
const planId = await this.vacay.getActivePlanId(user.id);
return { years: await this.vacay.addYear(planId, body.year as number, socketId) };
}
@Delete('years/:year')
deleteYear(@CurrentUser() user: User, @Param('year') yearParam: string, @Headers('x-socket-id') socketId?: string) {
async deleteYear(@CurrentUser() user: User, @Param('year') yearParam: string, @Headers('x-socket-id') socketId?: string) {
const year = Number.parseInt(yearParam);
const planId = this.vacay.getActivePlanId(user.id);
return { years: this.vacay.deleteYear(planId, year, socketId) };
const planId = await this.vacay.getActivePlanId(user.id);
return { years: await this.vacay.deleteYear(planId, year, socketId) };
}
@Get('year-settings')
yearSettings(@CurrentUser() user: User) {
return { settings: this.vacay.getYearSettings(user.id) };
async yearSettings(@CurrentUser() user: User) {
return { settings: await this.vacay.getYearSettings(user.id) };
}
@Put('year-settings')
updateYearSettings(
async updateYearSettings(
@CurrentUser() user: User,
@Body() body: VacayYearSettingsDto,
) {
return { settings: this.vacay.updateYearSettings(user.id, body) };
return { settings: await this.vacay.updateYearSettings(user.id, body) };
}
@Get('entries/:year')
entries(@CurrentUser() user: User, @Param('year') year: string) {
const planId = this.vacay.getActivePlanId(user.id);
async entries(@CurrentUser() user: User, @Param('year') year: string) {
const planId = await this.vacay.getActivePlanId(user.id);
// Entries load over the caller's leave-year window (#737), so a shifted year
// returns both calendar halves the grid renders.
return this.vacay.getEntries(planId, year, user.id);
@@ -215,21 +215,21 @@ export class VacayController {
@Post('entries/toggle')
@HttpCode(200)
toggleEntry(
async toggleEntry(
@CurrentUser() user: User,
@Body() body: VacayToggleEntryDto,
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
const planId = await this.vacay.getActivePlanId(user.id);
let userId = user.id;
if (body.target_user_id && Number.parseInt(String(body.target_user_id)) !== user.id) {
const tid = Number.parseInt(String(body.target_user_id));
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === tid)) {
if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === tid)) {
throw new HttpException({ error: 'User not in plan' }, 403);
}
userId = tid;
}
const result = this.vacay.toggleEntry(userId, planId, body.date, body.fraction, body.kind, socketId);
const result = await this.vacay.toggleEntry(userId, planId, body.date, body.fraction, body.kind, socketId);
if (result.error === 'weekend_blocked') {
throw new HttpException({ error: 'Weekend days are blocked on this plan' }, 400);
}
@@ -238,36 +238,36 @@ export class VacayController {
@Post('entries/company-holiday')
@HttpCode(200)
companyHoliday(
async companyHoliday(
@CurrentUser() user: User,
@Body() body: VacayCompanyHolidayDto,
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
const planId = await this.vacay.getActivePlanId(user.id);
return this.vacay.toggleCompanyHoliday(planId, body.date, body.note, socketId);
}
@Get('stats/:year')
stats(@CurrentUser() user: User, @Param('year') yearParam: string) {
async stats(@CurrentUser() user: User, @Param('year') yearParam: string) {
const year = Number.parseInt(yearParam);
const planId = this.vacay.getActivePlanId(user.id);
return { stats: this.vacay.getStats(planId, year) };
const planId = await this.vacay.getActivePlanId(user.id);
return { stats: await this.vacay.getStats(planId, year) };
}
@Put('stats/:year')
updateStats(
async updateStats(
@CurrentUser() user: User,
@Param('year') yearParam: string,
@Body() body: VacayUpdateStatsDto,
@Headers('x-socket-id') socketId?: string,
) {
const year = Number.parseInt(yearParam);
const planId = this.vacay.getActivePlanId(user.id);
const planId = await this.vacay.getActivePlanId(user.id);
const userId = body.target_user_id ? Number.parseInt(String(body.target_user_id)) : user.id;
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === userId)) {
if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === userId)) {
throw new HttpException({ error: 'User not in plan' }, 403);
}
this.vacay.updateStats(userId, planId, year, body.vacation_days as number, socketId);
await this.vacay.updateStats(userId, planId, year, body.vacation_days as number, socketId);
return { success: true };
}
@@ -278,7 +278,7 @@ export class VacayController {
@Post('shares')
@HttpCode(200)
share(
async share(
@CurrentUser() user: User,
@Body() body: VacayShareDto,
@Headers('x-socket-id') socketId?: string,
@@ -286,7 +286,7 @@ export class VacayController {
if (!body.user_id) {
throw new HttpException({ error: 'user_id required' }, 400);
}
const result = this.vacay.shareCalendar(user.id, user.email, Number.parseInt(String(body.user_id)), socketId);
const result = await this.vacay.shareCalendar(user.id, user.email, Number.parseInt(String(body.user_id)), socketId);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -294,31 +294,31 @@ export class VacayController {
}
@Get('shares/available-users')
shareAvailableUsers(@CurrentUser() user: User) {
return { users: this.vacay.getShareAvailableUsers(user.id) };
async shareAvailableUsers(@CurrentUser() user: User) {
return { users: await this.vacay.getShareAvailableUsers(user.id) };
}
@Get('shares/calendars/:year')
sharedCalendars(@CurrentUser() user: User, @Param('year') year: string) {
return { calendars: this.vacay.getSharedCalendars(user.id, year) };
async sharedCalendars(@CurrentUser() user: User, @Param('year') year: string) {
return { calendars: await this.vacay.getSharedCalendars(user.id, year) };
}
@Put('shares/:id')
updateShare(
async updateShare(
@CurrentUser() user: User,
@Param('id') idParam: string,
@Body() body: VacayShareUpdateDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.vacay.setShareHidden(Number.parseInt(idParam), user.id, body.hidden, socketId)) {
if (!(await this.vacay.setShareHidden(Number.parseInt(idParam), user.id, body.hidden, socketId))) {
throw new HttpException({ error: 'Share not found' }, 404);
}
return { success: true };
}
@Delete('shares/:id')
deleteShare(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
if (!this.vacay.removeShare(Number.parseInt(idParam), user.id, socketId)) {
async deleteShare(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
if (!(await this.vacay.removeShare(Number.parseInt(idParam), user.id, socketId))) {
throw new HttpException({ error: 'Share not found' }, 404);
}
return { success: true };
+46 -46
View File
@@ -92,7 +92,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getVacayPlan(_args: Record<string, never>, ctx: McpContext) {
const plan = this.vacay.getPlanData(ctx.userId);
const plan = await this.vacay.getPlanData(ctx.userId);
return ok({ plan });
}
@@ -105,7 +105,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getVacayYearSettings(_args: Record<string, never>, ctx: McpContext) {
return ok({ settings: this.vacay.getYearSettings(ctx.userId) });
return ok({ settings: await this.vacay.getYearSettings(ctx.userId) });
}
@Tool({
@@ -128,7 +128,7 @@ export class VacayMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
// updatePlan already returns the fully-hydrated { plan }; surface it so the
// AI consumer sees the updated plan, matching get_vacay_plan.
const result = await this.vacay.updatePlan(planId, {
@@ -150,8 +150,8 @@ export class VacayMcp {
})
async setVacayColor({ color }: { color: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.setUserColor(ctx.userId, planId, color, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.setUserColor(ctx.userId, planId, color, undefined);
// Echo the persisted color (mirrors the service default) so the AI consumer sees what was set.
return ok({ success: true, color: color || '#6366f1' });
}
@@ -165,8 +165,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getAvailableVacayUsers(_args: Record<string, never>, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const users = this.vacay.getAvailableUsers(ctx.userId, planId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const users = await this.vacay.getAvailableUsers(ctx.userId, planId);
return ok({ users });
}
@@ -182,10 +182,10 @@ export class VacayMcp {
})
async sendVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const me = await this.auth.getCurrentUser(ctx.userId);
if (!me) return errorResult('User not found.');
const result = this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId);
const result = await this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId);
if (result.error) return errorResult(result.error);
return ok({ success: true });
}
@@ -202,7 +202,7 @@ export class VacayMcp {
})
async acceptVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const result = this.vacay.acceptInvite(ctx.userId, planId, undefined);
const result = await this.vacay.acceptInvite(ctx.userId, planId, undefined);
if (result.error) return errorResult(result.error);
return ok({ success: true });
}
@@ -219,7 +219,7 @@ export class VacayMcp {
})
async declineVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
this.vacay.declineInvite(ctx.userId, planId, undefined);
await this.vacay.declineInvite(ctx.userId, planId, undefined);
return ok({ success: true });
}
@@ -235,8 +235,8 @@ export class VacayMcp {
})
async cancelVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.cancelInvite(planId, targetUserId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.cancelInvite(planId, targetUserId);
return ok({ success: true });
}
@@ -250,7 +250,7 @@ export class VacayMcp {
})
async dissolveVacayPlan(_args: Record<string, never>, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
this.vacay.dissolvePlan(ctx.userId, undefined);
await this.vacay.dissolvePlan(ctx.userId, undefined);
return ok({ success: true });
}
@@ -263,8 +263,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async listVacayYears(_args: Record<string, never>, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const years = this.vacay.listYears(planId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const years = await this.vacay.listYears(planId);
return ok({ years });
}
@@ -280,8 +280,8 @@ export class VacayMcp {
})
async addVacayYear({ year }: { year: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const years = this.vacay.addYear(planId, year, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const years = await this.vacay.addYear(planId, year, undefined);
return ok({ years });
}
@@ -297,8 +297,8 @@ export class VacayMcp {
})
async deleteVacayYear({ year }: { year: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const years = this.vacay.deleteYear(planId, year, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const years = await this.vacay.deleteYear(planId, year, undefined);
return ok({ years });
}
@@ -313,8 +313,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getVacayEntries({ year }: { year: number }, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const entries = this.vacay.getEntries(planId, String(year), ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const entries = await this.vacay.getEntries(planId, String(year), ctx.userId);
return ok({ entries });
}
@@ -336,17 +336,17 @@ export class VacayMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
let userId = ctx.userId;
if (targetUserId !== undefined && targetUserId !== ctx.userId) {
// Same gate as POST /entries/toggle: booking for someone else reaches only
// the members of the caller's own plan, and nobody outside it.
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === targetUserId)) {
if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === targetUserId)) {
return errorResult('User not in plan');
}
userId = targetUserId;
}
const result = this.vacay.toggleEntry(userId, planId, date, fraction, kind, undefined);
const result = await this.vacay.toggleEntry(userId, planId, date, fraction, kind, undefined);
if (result.error) return errorResult(result.error);
return ok(result);
}
@@ -364,8 +364,8 @@ export class VacayMcp {
})
async toggleCompanyHoliday({ date, note }: { date: string; note?: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const result = this.vacay.toggleCompanyHoliday(planId, date, note, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const result = await this.vacay.toggleCompanyHoliday(planId, date, note, undefined);
return ok(result);
}
@@ -380,8 +380,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getVacayStats({ year }: { year: number }, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const stats = this.vacay.getStats(planId, year);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const stats = await this.vacay.getStats(planId, year);
return ok({ stats });
}
@@ -398,8 +398,8 @@ export class VacayMcp {
})
async updateVacayStats({ year, vacationDays }: { year: number; vacationDays: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.updateStats(ctx.userId, planId, year, vacationDays, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.updateStats(ctx.userId, planId, year, vacationDays, undefined);
return ok({ success: true });
}
@@ -422,10 +422,10 @@ export class VacayMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
// An omitted type stays undefined so the service default (and the default
// colour that goes with it) applies, exactly as on the REST route.
const calendar = this.vacay.addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined, type);
const calendar = await this.vacay.addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined, type);
return ok({ calendar });
}
@@ -446,8 +446,8 @@ export class VacayMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const cal = this.vacay.updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const cal = await this.vacay.updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
if (!cal) return errorResult('Holiday calendar not found.');
return ok({ calendar: cal });
}
@@ -464,8 +464,8 @@ export class VacayMcp {
})
async deleteHolidayCalendar({ calendarId }: { calendarId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.deleteHolidayCalendar(calendarId, planId, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.deleteHolidayCalendar(calendarId, planId, undefined);
return ok({ success: true });
}
@@ -547,7 +547,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async listVacayShares(_args: Record<string, never>, ctx: McpContext) {
return ok(this.vacay.listShares(ctx.userId));
return ok(await this.vacay.listShares(ctx.userId));
}
@Tool({
@@ -559,7 +559,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getShareableVacayUsers(_args: Record<string, never>, ctx: McpContext) {
return ok({ users: this.vacay.getShareAvailableUsers(ctx.userId) });
return ok({ users: await this.vacay.getShareAvailableUsers(ctx.userId) });
}
@Tool({
@@ -576,7 +576,7 @@ export class VacayMcp {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const me = await this.auth.getCurrentUser(ctx.userId);
if (!me) return errorResult('User not found.');
const result = this.vacay.shareCalendar(ctx.userId, me.email, targetUserId);
const result = await this.vacay.shareCalendar(ctx.userId, me.email, targetUserId);
if (result.error) return errorResult(result.error);
return ok({ success: true });
}
@@ -593,7 +593,7 @@ export class VacayMcp {
})
async unshareVacayCalendar({ shareId }: { shareId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.vacay.removeShare(shareId, ctx.userId, undefined)) {
if (!(await this.vacay.removeShare(shareId, ctx.userId, undefined))) {
return errorResult('Share not found.');
}
return ok({ success: true });
@@ -610,7 +610,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getSharedVacayCalendars({ year }: { year: number }, ctx: McpContext) {
return ok({ calendars: this.vacay.getSharedCalendars(ctx.userId, String(year)) });
return ok({ calendars: await this.vacay.getSharedCalendars(ctx.userId, String(year)) });
}
// -------------------------------------------------------------------------
@@ -626,7 +626,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async vacayPlanResource(uri: URL, ctx: McpContext) {
const plan = this.vacay.getPlanData(ctx.userId);
const plan = await this.vacay.getPlanData(ctx.userId);
return {
contents: [{
uri: uri.href,
@@ -645,8 +645,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async vacayEntriesResource(uri: URL, { year }: { year: string | string[] }, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const entries = this.vacay.getEntries(planId, Array.isArray(year) ? year[0] : year, ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const entries = await this.vacay.getEntries(planId, Array.isArray(year) ? year[0] : year, ctx.userId);
return {
contents: [{
uri: uri.href,
@@ -665,7 +665,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async vacayHolidaysResource(uri: URL, { year }: { year: string | string[] }, ctx: McpContext) {
const plan = this.vacay.getActivePlan(ctx.userId);
const plan = await this.vacay.getActivePlan(ctx.userId);
const json = (data: unknown) => ({
contents: [{
uri: uri.href,
+2 -2
View File
@@ -31,7 +31,7 @@ export class VacayRpc {
const userId = this.requireVacayUser(ctx, 'writes');
const date = this.dateStr(params.date);
await this.requireVacayAddon();
return this.vacay.toggleEntry(userId, this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined);
return await this.vacay.toggleEntry(userId, await this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined);
}
@PluginMethod('vacay.toggleCompanyHoliday', { permission: 'db:write:vacay' })
@@ -40,7 +40,7 @@ export class VacayRpc {
const date = this.dateStr(params.date);
const note = typeof params.note === 'string' ? params.note.slice(0, 256) : undefined;
await this.requireVacayAddon();
return this.vacay.toggleCompanyHoliday(this.vacay.getActivePlanId(userId), date, note, undefined);
return await this.vacay.toggleCompanyHoliday(await this.vacay.getActivePlanId(userId), date, note, undefined);
}
private requireVacayUser(ctx: PluginRpcContext, kind: 'reads' | 'writes'): number {
+161 -138
View File
@@ -2,6 +2,7 @@ import { Injectable, BadRequestException } from '@nestjs/common';
import { manualSchoolRegionId } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { NotificationsService } from '../notifications/notifications.service';
import { discardBody, readCappedJson } from '../../utils/cappedFetch';
@@ -187,6 +188,7 @@ export class VacayService {
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
private readonly uow: UnitOfWork,
) {}
private readonly holidayCache = new Map<string, { data: unknown; time: number }>();
@@ -200,12 +202,12 @@ export class VacayService {
* half day (#552) counts as 0.5 and a full day as 1. Entries predating the
* feature have fraction = 1, so this matches the old COUNT(*) for them.
*/
private usedDays(userId: number, planId: number, year: number): number {
private async usedDays(userId: number, planId: number, year: number): Promise<number> {
// Comp/Flex days (#1074) are free — kind='comp' contributes 0 to the entitlement,
// vacation days contribute their fraction. Entries predating the column are
// 'vacation' by default. The window (#737) is the user's leave-year period; for
// 'calendar' it is Jan 1 – Dec 31, byte-identical to the old date-prefix match.
const { start, end } = this.resolveYearWindow(userId, year);
const { start, end } = await this.resolveYearWindow(userId, year);
const row = this.db.get<{ used: number }>(
"SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction END), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ?",
userId, planId, start, end
@@ -214,8 +216,8 @@ export class VacayService {
}
/** Comp/Flex days (#1074) used in a user's leave-year period — SUM of fractions for kind='comp'. */
private compUsedDays(userId: number, planId: number, year: number): number {
const { start, end } = this.resolveYearWindow(userId, year);
private async compUsedDays(userId: number, planId: number, year: number): Promise<number> {
const { start, end } = await this.resolveYearWindow(userId, year);
const row = this.db.get<{ used: number }>(
"SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ? AND kind = 'comp'",
userId, planId, start, end
@@ -227,13 +229,13 @@ export class VacayService {
// Configurable vacation year (#737)
// -------------------------------------------------------------------------
getUserYearSettings(userId: number): VacayUserSettings | undefined {
async getUserYearSettings(userId: number): Promise<VacayUserSettings | undefined> {
return this.db.get<VacayUserSettings>('SELECT * FROM vacay_user_settings WHERE user_id = ?', userId);
}
/** A user's leave-year settings with the calendar defaults filled in (#737). */
getYearSettings(userId: number): VacayUserSettings {
return this.getUserYearSettings(userId) ?? { user_id: userId, year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null };
async getYearSettings(userId: number): Promise<VacayUserSettings> {
return (await this.getUserYearSettings(userId)) ?? { user_id: userId, year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null };
}
/**
@@ -244,8 +246,8 @@ export class VacayService {
* end exclusive. Because periods are consecutive, `year-1` is always the window that
* ends where `year`'s begins, so the carry-over chains stay valid unchanged.
*/
resolveYearWindow(userId: number, year: number): { start: string; end: string } {
const s = this.getUserYearSettings(userId);
async resolveYearWindow(userId: number, year: number): Promise<{ start: string; end: string }> {
const s = await this.getUserYearSettings(userId);
// 'anniversary' before a hire date is entered has nothing to anchor to, so it
// reads as the calendar default rather than borrowing a month left behind by a
// previous 'fiscal' setting — the client mirror resolves it the same way.
@@ -272,13 +274,13 @@ export class VacayService {
* (#737). Without a viewer — MCP resources, which are plan-scoped — it stays the
* plain calendar year, which is exactly what a 'calendar' user resolves to.
*/
private viewerWindow(year: number | string, viewerId?: number): { start: string; end: string } {
private async viewerWindow(year: number | string, viewerId?: number): Promise<{ start: string; end: string }> {
const y = typeof year === 'number' ? year : Number.parseInt(year, 10);
// A non-numeric year matched nothing under the old date prefix; an empty range
// matches nothing either, so a bad parameter still yields an empty result.
if (!Number.isFinite(y)) return { start: '', end: '' };
if (viewerId == null) return { start: `${y}-01-01`, end: `${y + 1}-01-01` };
return this.resolveYearWindow(viewerId, y);
return await this.resolveYearWindow(viewerId, y);
}
/**
@@ -294,8 +296,8 @@ export class VacayService {
* only the grid's edges are approximate. Rendering a 13th month card would fix it
* and was deliberately not taken.
*/
private viewerGridWindow(year: number | string, viewerId?: number): { start: string; end: string } {
const w = this.viewerWindow(year, viewerId);
private async viewerGridWindow(year: number | string, viewerId?: number): Promise<{ start: string; end: string }> {
const w = await this.viewerWindow(year, viewerId);
if (!w.start) return w;
return { start: `${w.start.slice(0, 7)}-01`, end: `${w.end.slice(0, 7)}-01` };
}
@@ -305,17 +307,17 @@ export class VacayService {
* window that starts later in the year, today still belongs to the period named
* after the previous calendar year — 'calendar' users always get today's year.
*/
currentPeriodYear(userId: number, date = new Date()): number {
async currentPeriodYear(userId: number, date = new Date()): Promise<number> {
const y = date.getFullYear();
const iso = `${y}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
return iso < this.resolveYearWindow(userId, y).start ? y - 1 : y;
return iso < (await this.resolveYearWindow(userId, y)).start ? y - 1 : y;
}
/** Upsert a user's leave-year settings (#737). */
updateYearSettings(
async updateYearSettings(
userId: number,
data: { year_type?: unknown; year_start_month?: unknown; year_start_day?: unknown; hire_date?: unknown },
): VacayUserSettings {
): Promise<VacayUserSettings> {
const type = normalizeYearType(data.year_type);
const month = Math.min(12, Math.max(1, Number.parseInt(String(data.year_start_month ?? 1), 10) || 1));
const day = Math.min(31, Math.max(1, Number.parseInt(String(data.year_start_day ?? 1), 10) || 1));
@@ -326,21 +328,21 @@ export class VacayService {
ON CONFLICT(user_id) DO UPDATE SET year_type = excluded.year_type, year_start_month = excluded.year_start_month,
year_start_day = excluded.year_start_day, hire_date = excluded.hire_date
`, userId, type, month, day, hire);
return this.getUserYearSettings(userId)!;
return (await this.getUserYearSettings(userId))!;
}
// -------------------------------------------------------------------------
// Plan management
// -------------------------------------------------------------------------
getOwnPlan(userId: number): VacayPlan {
async getOwnPlan(userId: number): Promise<VacayPlan> {
let plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE owner_id = ?', userId);
if (!plan) {
this.db.run('INSERT INTO vacay_plans (owner_id) VALUES (?)', userId);
plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE owner_id = ?', userId)!;
// Seed the period today falls into — with a shifted leave year (#737) that is
// not necessarily the current calendar year.
const yr = this.currentPeriodYear(userId);
const yr = await this.currentPeriodYear(userId);
this.db.run('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)', plan.id, yr);
this.db.run('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)', userId, plan.id, yr);
this.db.run('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)', userId, plan.id, '#6366f1');
@@ -348,26 +350,26 @@ export class VacayService {
return plan;
}
getActivePlan(userId: number): VacayPlan {
async getActivePlan(userId: number): Promise<VacayPlan> {
const membership = this.db.get<{ plan_id: number }>(`
SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'
`, userId);
if (membership) {
return this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', membership.plan_id)!;
}
return this.getOwnPlan(userId);
return await this.getOwnPlan(userId);
}
getActivePlanId(userId: number): number {
return this.getActivePlan(userId).id;
async getActivePlanId(userId: number): Promise<number> {
return (await this.getActivePlan(userId)).id;
}
shiftOwnerEntriesForTripWindow(
async shiftOwnerEntriesForTripWindow(
ownerId: number,
oldStart: string,
oldEnd: string,
newStart: string
): void {
): Promise<void> {
const row = this.db.get<{ days: number }>(
'SELECT CAST(julianday(?) - julianday(?) AS INTEGER) AS days',
newStart, oldStart
@@ -375,7 +377,7 @@ export class VacayService {
const offset = row?.days ?? 0;
if (offset === 0) return;
const plan = this.getOwnPlan(ownerId);
const plan = await this.getOwnPlan(ownerId);
this.db.run(
`UPDATE OR IGNORE vacay_entries
@@ -387,7 +389,7 @@ export class VacayService {
);
}
getPlanUsers(planId: number): VacayUser[] {
async getPlanUsers(planId: number): Promise<VacayUser[]> {
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
if (!plan) return [];
const owner = this.db.get<VacayUser>('SELECT id, username, email FROM users WHERE id = ?', plan.owner_id)!;
@@ -403,11 +405,11 @@ export class VacayService {
// WebSocket notifications
// -------------------------------------------------------------------------
notifyPlanUsers(
async notifyPlanUsers(
planId: number,
excludeSid: string | undefined,
event: 'vacay:update' | 'vacay:settings' | 'vacay:accepted' | 'vacay:declined' = 'vacay:update',
): void {
): Promise<void> {
try {
const plan = this.db.get<{ owner_id: number }>('SELECT owner_id FROM vacay_plans WHERE id = ?', planId);
if (!plan) return;
@@ -420,12 +422,12 @@ export class VacayService {
// union proves invite/cancelled never reach this method — their senders
// call broadcastToUser directly — so only declined needs excluding here.)
if (event !== 'vacay:declined') {
this.notifyShareViewers(userIds, excludeSid);
await this.notifyShareViewers(userIds, excludeSid);
}
} catch { /* websocket not available */ }
}
notifyShareViewers(ownerIds: number[], excludeSid?: string): void {
async notifyShareViewers(ownerIds: number[], excludeSid?: string): Promise<void> {
if (ownerIds.length === 0) return;
try {
const rows = this.db.all<{ user_id: number }>(
@@ -449,11 +451,11 @@ export class VacayService {
// A shifted leave year (#737) runs into the next calendar year, so collect the
// calendar years the members' windows actually touch — not just the period ids.
// With everyone on 'calendar' this is the same set as before.
const members = this.getPlanUsers(planId);
const members = await this.getPlanUsers(planId);
const calendarYears = new Set<number>();
for (const { year } of years) {
calendarYears.add(year);
for (const m of members) calendarYears.add(windowEndYear(this.resolveYearWindow(m.id, year).end));
for (const m of members) calendarYears.add(windowEndYear((await this.resolveYearWindow(m.id, year)).end));
}
for (const cal of calendars) {
const country = cal.region.split('-')[0];
@@ -537,14 +539,14 @@ export class VacayService {
if (carry_over_enabled === true) {
// The chained per-year/per-user recompute is atomic — a failure mid-chain
// would otherwise leave later years carrying stale balances.
this.db.transaction(() => {
await this.uow.transactional(async () => {
const years = this.db.all<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year', planId);
const users = this.getPlanUsers(planId);
const users = await this.getPlanUsers(planId);
for (let i = 0; i < years.length - 1; i++) {
const yr = years[i].year;
const nextYr = years[i + 1].year;
for (const u of users) {
const used = this.usedDays(u.id, planId, yr);
const used = await this.usedDays(u.id, planId, yr);
const config = this.db.get<VacayUserYear>('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, yr);
const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0);
const carry = Math.max(0, total - used);
@@ -557,7 +559,7 @@ export class VacayService {
});
}
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
const updated = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId)!;
const updatedCalendars = this.db.all<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id', planId);
@@ -578,26 +580,26 @@ export class VacayService {
// Holiday calendars CRUD
// -------------------------------------------------------------------------
addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') {
this.validateManualRegion(region, type);
async addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') {
await this.validateManualRegion(region, type);
const result = this.db.run(
'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0
);
const cal = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ?', result.lastInsertRowid)!;
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return cal;
}
updateHolidayCalendar(
async updateHolidayCalendar(
calId: number,
planId: number,
body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' },
socketId: string | undefined,
): VacayHolidayCalendar | null {
): Promise<VacayHolidayCalendar | null> {
const cal = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId);
if (!cal) return null;
this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type);
await this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type);
const { region, label, color, sort_order, type } = body;
const updates: string[] = [];
const params: (string | number | null)[] = [];
@@ -611,19 +613,19 @@ export class VacayService {
this.db.run(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`, ...params);
}
const updated = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ?', calId)!;
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return updated;
}
deleteHolidayCalendar(calId: number, planId: number, socketId: string | undefined): boolean {
async deleteHolidayCalendar(calId: number, planId: number, socketId: string | undefined): Promise<boolean> {
const cal = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId);
if (!cal) return false;
this.db.run('DELETE FROM vacay_holiday_calendars WHERE id = ?', calId);
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return true;
}
private validateManualRegion(code: string, type: string) {
private async validateManualRegion(code: string, type: string) {
if (!code.includes('-MANUAL-')) return;
const id = manualSchoolRegionId(code);
if (type !== 'school_holiday' || !id || !this.db.get('SELECT id FROM school_holiday_regions WHERE id = ? AND country = ?', id, code.slice(0, 2))) {
@@ -635,19 +637,19 @@ export class VacayService {
// User colors
// -------------------------------------------------------------------------
setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): void {
async setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): Promise<void> {
this.db.run(`
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
`, userId, planId, color || '#6366f1');
this.notifyPlanUsers(planId, socketId, 'vacay:update');
await this.notifyPlanUsers(planId, socketId, 'vacay:update');
}
// -------------------------------------------------------------------------
// Invitations
// -------------------------------------------------------------------------
sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): { error?: string; status?: number } {
async sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): Promise<{ error?: string; status?: number }> {
if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 };
// The picker no longer offers guests, but the id arrives from the client, so the
@@ -684,10 +686,10 @@ export class VacayService {
return {};
}
acceptInvite(userId: number, planId: number, socketId: string | undefined): { error?: string; status?: number } {
async acceptInvite(userId: number, planId: number, socketId: string | undefined): Promise<{ error?: string; status?: number }> {
// The accept flow is a multi-statement write (status flip + entry/year/color
// migration + seeding) — atomic, so a failure can't leave the member half-fused.
const result = this.db.transaction((): { error?: string; status?: number } => {
const result = await this.uow.transactional(async (): Promise<{ error?: string; status?: number }> => {
const invite = this.db.get<VacayPlanMember>("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, userId);
if (!invite) return { error: 'No pending invite', status: 404 };
@@ -731,16 +733,16 @@ export class VacayService {
// Only announce a fusion that actually happened — the transaction returns the
// refusal for an invite that was already gone.
if (!result.error) this.notifyPlanUsers(planId, socketId, 'vacay:accepted');
if (!result.error) await this.notifyPlanUsers(planId, socketId, 'vacay:accepted');
return result;
}
declineInvite(userId: number, planId: number, socketId: string | undefined): void {
async declineInvite(userId: number, planId: number, socketId: string | undefined): Promise<void> {
this.db.run("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, userId);
this.notifyPlanUsers(planId, socketId, 'vacay:declined');
await this.notifyPlanUsers(planId, socketId, 'vacay:declined');
}
cancelInvite(planId: number, targetUserId: number): void {
async cancelInvite(planId: number, targetUserId: number): Promise<void> {
this.db.run("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, targetUserId);
try {
@@ -752,20 +754,20 @@ export class VacayService {
// Plan dissolution
// -------------------------------------------------------------------------
dissolvePlan(userId: number, socketId: string | undefined): void {
async dissolvePlan(userId: number, socketId: string | undefined): Promise<void> {
// Dissolution moves every member's entries back to their own plan and copies
// the company holidays — atomic, so a failure can't strand entries between plans.
const allUserIds = this.db.transaction(() => {
const plan = this.getActivePlan(userId);
const allUserIds = await this.uow.transactional(async () => {
const plan = await this.getActivePlan(userId);
const isOwnerFlag = plan.owner_id === userId;
const userIds = this.getPlanUsers(plan.id).map(u => u.id);
const userIds = (await this.getPlanUsers(plan.id)).map(u => u.id);
const companyHolidays = this.db.all<{ date: string; note: string }>('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?', plan.id);
if (isOwnerFlag) {
const members = this.db.all<{ user_id: number }>("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'", plan.id);
for (const m of members) {
const memberPlan = this.getOwnPlan(m.user_id);
const memberPlan = await this.getOwnPlan(m.user_id);
this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', memberPlan.id, plan.id, m.user_id);
for (const ch of companyHolidays) {
this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', memberPlan.id, ch.date, ch.note);
@@ -773,7 +775,7 @@ export class VacayService {
}
this.db.run('DELETE FROM vacay_plan_members WHERE plan_id = ?', plan.id);
} else {
const ownPlan = this.getOwnPlan(userId);
const ownPlan = await this.getOwnPlan(userId);
this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', ownPlan.id, plan.id, userId);
for (const ch of companyHolidays) {
this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', ownPlan.id, ch.date, ch.note);
@@ -787,14 +789,14 @@ export class VacayService {
allUserIds.filter(id => id !== userId).forEach(id => this.realtime.broadcastToUser(id, { type: 'vacay:dissolved' }));
} catch { /* */ }
// Everyone's entries just moved back to their own plans — refresh read-only viewers.
this.notifyShareViewers(allUserIds, socketId);
await this.notifyShareViewers(allUserIds, socketId);
}
// -------------------------------------------------------------------------
// Available users
// -------------------------------------------------------------------------
getAvailableUsers(userId: number, planId: number) {
async getAvailableUsers(userId: number, planId: number) {
return this.db.all(`
SELECT u.id, u.username, u.email FROM users u
WHERE u.id != ?
@@ -818,7 +820,7 @@ export class VacayService {
// the owner's entries in whatever plan the owner is currently active in.
/** Like getActivePlan, but never lazily creates a plan for the user. */
private peekActivePlan(userId: number): VacayPlan | undefined {
private async peekActivePlan(userId: number): Promise<VacayPlan | undefined> {
const membership = this.db.get<{ plan_id: number }>(`
SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'
`, userId);
@@ -829,8 +831,8 @@ export class VacayService {
}
/** Colors already taken in the viewer's own calendar (their plan's members). */
private viewerColors(viewerId: number): Set<string> {
const plan = this.peekActivePlan(viewerId);
private async viewerColors(viewerId: number): Promise<Set<string>> {
const plan = await this.peekActivePlan(viewerId);
if (!plan) return new Set(['#6366f1']);
const rows = this.db.all<{ color: string }>('SELECT color FROM vacay_user_colors WHERE plan_id = ?', plan.id);
return new Set(rows.length > 0 ? rows.map(r => r.color) : ['#6366f1']);
@@ -842,8 +844,8 @@ export class VacayService {
* members or an earlier share — otherwise two people on the default indigo
* would be indistinguishable in the overlay.
*/
private shareDisplayColor(ownerId: number, usedColors: Set<string>): string {
const plan = this.peekActivePlan(ownerId);
private async shareDisplayColor(ownerId: number, usedColors: Set<string>): Promise<string> {
const plan = await this.peekActivePlan(ownerId);
const row = plan
? this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', ownerId, plan.id)
: undefined;
@@ -857,12 +859,12 @@ export class VacayService {
}
/** Users the viewer already sees in full via their active plan (owner + members). */
private viewerCoMemberIds(viewerId: number): Set<number> {
const plan = this.peekActivePlan(viewerId);
return new Set(plan ? this.getPlanUsers(plan.id).map(u => u.id) : []);
private async viewerCoMemberIds(viewerId: number): Promise<Set<number>> {
const plan = await this.peekActivePlan(viewerId);
return new Set(plan ? (await this.getPlanUsers(plan.id)).map(u => u.id) : []);
}
listShares(userId: number) {
async listShares(userId: number) {
// Usernames only, like the share picker — emails stay out of the share surface.
const outgoing = this.db.all<{ id: number; user_id: number; username: string }>(`
SELECT s.id, s.user_id, u.username
@@ -876,19 +878,25 @@ export class VacayService {
`, userId);
// Shares from someone the viewer is meanwhile fused with lie dormant — the
// plan already shows that calendar in full. They resume after dissolution.
const coMembers = this.viewerCoMemberIds(userId);
const usedColors = this.viewerColors(userId);
const incoming = incomingRows.filter(s => !coMembers.has(s.owner_id)).map(s => ({
id: s.id,
owner_id: s.owner_id,
username: s.username,
color: this.shareDisplayColor(s.owner_id, usedColors),
hidden: !!s.hidden,
}));
const coMembers = await this.viewerCoMemberIds(userId);
const usedColors = await this.viewerColors(userId);
// `map` cannot await, and `shareDisplayColor` mutates `usedColors` as it
// hands colors out, so the projection runs as an explicit loop — same rows,
// same order, same color-allocation sequence.
const incoming = [];
for (const s of incomingRows.filter(s => !coMembers.has(s.owner_id))) {
incoming.push({
id: s.id,
owner_id: s.owner_id,
username: s.username,
color: await this.shareDisplayColor(s.owner_id, usedColors),
hidden: !!s.hidden,
});
}
return { outgoing, incoming };
}
shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): { error?: string; status?: number } {
async shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): Promise<{ error?: string; status?: number }> {
if (targetUserId === ownerId) return { error: 'Cannot share with yourself', status: 400 };
const targetUser = this.db.get('SELECT id FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0', targetUserId);
@@ -898,7 +906,7 @@ export class VacayService {
if (existing) return { error: 'Already shared', status: 400 };
// Plan members already see the whole calendar — sharing with them is moot.
if (this.getPlanUsers(this.getActivePlanId(ownerId)).find(u => u.id === targetUserId)) {
if ((await this.getPlanUsers(await this.getActivePlanId(ownerId))).find(u => u.id === targetUserId)) {
return { error: 'User is already in your calendar', status: 400 };
}
@@ -915,7 +923,7 @@ export class VacayService {
return {};
}
removeShare(shareId: number, userId: number, socketId?: string): boolean {
async removeShare(shareId: number, userId: number, socketId?: string): Promise<boolean> {
const share = this.db.get<VacayShare>('SELECT * FROM vacay_shares WHERE id = ?', shareId);
// The owner revokes, the viewer removes — both may delete, nobody else.
if (!share || (share.owner_id !== userId && share.user_id !== userId)) return false;
@@ -927,7 +935,7 @@ export class VacayService {
return true;
}
setShareHidden(shareId: number, userId: number, hidden: boolean, socketId?: string): boolean {
async setShareHidden(shareId: number, userId: number, hidden: boolean, socketId?: string): Promise<boolean> {
const share = this.db.get<VacayShare>('SELECT * FROM vacay_shares WHERE id = ?', shareId);
if (!share || share.user_id !== userId) return false;
this.db.run('UPDATE vacay_shares SET hidden = ? WHERE id = ?', hidden ? 1 : 0, shareId);
@@ -938,8 +946,8 @@ export class VacayService {
return true;
}
getShareAvailableUsers(userId: number) {
const planId = this.getActivePlanId(userId);
async getShareAvailableUsers(userId: number) {
const planId = await this.getActivePlanId(userId);
// Username only — unlike the fusion picker this lists users from other plans
// too, so exposing their emails here would widen the instance directory.
return this.db.all(`
@@ -956,10 +964,10 @@ export class VacayService {
`, userId, userId, planId, planId);
}
getSharedCalendars(viewerId: number, year: string) {
async getSharedCalendars(viewerId: number, year: string) {
// Shared calendars are drawn into the viewer's grid, so they load over the
// viewer's range (#737) even when the owner's leave year is shaped differently.
const { start, end } = this.viewerGridWindow(year, viewerId);
const { start, end } = await this.viewerGridWindow(year, viewerId);
const shares = this.db.all<{ id: number; owner_id: number; hidden: number; username: string }>(`
SELECT s.id, s.owner_id, s.hidden, u.username
FROM vacay_shares s JOIN users u ON s.owner_id = u.id
@@ -967,13 +975,18 @@ export class VacayService {
`, viewerId);
// Same dormancy rule as listShares: fused co-members are already fully visible.
const coMembers = this.viewerCoMemberIds(viewerId);
const usedColors = this.viewerColors(viewerId);
return shares.filter(s => !coMembers.has(s.owner_id)).map(s => {
const color = this.shareDisplayColor(s.owner_id, usedColors);
const plan = this.peekActivePlan(s.owner_id);
const coMembers = await this.viewerCoMemberIds(viewerId);
const usedColors = await this.viewerColors(viewerId);
// `map` cannot await, and `shareDisplayColor` mutates `usedColors` as it
// hands colors out, so the projection runs as an explicit loop — same rows,
// same order, same color-allocation sequence.
const calendars = [];
for (const s of shares.filter(s => !coMembers.has(s.owner_id))) {
const color = await this.shareDisplayColor(s.owner_id, usedColors);
const plan = await this.peekActivePlan(s.owner_id);
if (!plan) {
return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries: [], companyHolidays: [] };
calendars.push({ share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries: [], companyHolidays: [] });
continue;
}
const entries = this.db.all(
'SELECT date, fraction, kind FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ? ORDER BY date',
@@ -985,36 +998,37 @@ export class VacayService {
const companyHolidays = plan.company_holidays_enabled
? this.db.all('SELECT date FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ? ORDER BY date', plan.id, start, end)
: [];
return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries, companyHolidays };
});
calendars.push({ share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries, companyHolidays });
}
return calendars;
}
// -------------------------------------------------------------------------
// Years
// -------------------------------------------------------------------------
listYears(planId: number): number[] {
async listYears(planId: number): Promise<number[]> {
const rows = this.db.all<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year', planId);
return rows.map(y => y.year);
}
addYear(planId: number, year: number, socketId: string | undefined): number[] {
async addYear(planId: number, year: number, socketId: string | undefined): Promise<number[]> {
// A duplicate year is a no-op (the legacy blanket try/catch was written for
// exactly this constraint hit); real errors now propagate instead of being
// swallowed. The insert + per-user seeding runs atomically.
const exists = this.db.get('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year);
if (!exists) {
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)', planId, year);
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = this.getPlanUsers(planId);
const users = await this.getPlanUsers(planId);
for (const u of users) {
let carriedOver = 0;
if (carryOverEnabled) {
const prevConfig = this.db.get<VacayUserYear>('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, year - 1);
if (prevConfig) {
const used = this.usedDays(u.id, planId, year - 1);
const used = await this.usedDays(u.id, planId, year - 1);
const total = prevConfig.vacation_days + prevConfig.carried_over;
carriedOver = Math.max(0, total - used);
}
@@ -1023,21 +1037,21 @@ export class VacayService {
}
});
}
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return this.listYears(planId);
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return await this.listYears(planId);
}
deleteYear(planId: number, year: number, socketId: string | undefined): number[] {
async deleteYear(planId: number, year: number, socketId: string | undefined): Promise<number[]> {
// Year removal deletes across four tables and recomputes the next year's
// carry-over — atomic, so a failure can't leave entries without their year.
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year);
// Members can be on differently shaped leave years (#737), so entries go per
// author over that author's period rather than by one shared year prefix.
// Authors are read off the entries themselves so orphans are cleared too.
const authors = this.db.all<{ user_id: number }>('SELECT DISTINCT user_id FROM vacay_entries WHERE plan_id = ?', planId);
for (const { user_id } of authors) {
const { start, end } = this.resolveYearWindow(user_id, year);
const { start, end } = await this.resolveYearWindow(user_id, year);
this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ?', planId, user_id, start, end);
}
// Company holidays belong to the plan, not to a member, and every member sees
@@ -1045,8 +1059,13 @@ export class VacayService {
// range is therefore the intersection of all member windows — anything outside
// it still sits inside a period somebody else has not deleted.
const owner = this.db.get<{ owner_id: number }>('SELECT owner_id FROM vacay_plans WHERE id = ?', planId);
const members = this.getPlanUsers(planId);
const windows = (members.length > 0 ? members.map(m => m.id) : [owner?.owner_id ?? -1]).map(id => this.resolveYearWindow(id, year));
const members = await this.getPlanUsers(planId);
// `map` cannot await the per-member window read, so it runs as an explicit
// loop — same ids, same order.
const windows = [];
for (const id of members.length > 0 ? members.map(m => m.id) : [owner?.owner_id ?? -1]) {
windows.push(await this.resolveYearWindow(id, year));
}
const holidayStart = windows.reduce((a, w) => (w.start > a ? w.start : a), windows[0].start);
const holidayEnd = windows.reduce((a, w) => (w.end < a ? w.end : a), windows[0].end);
if (holidayStart < holidayEnd) {
@@ -1059,7 +1078,7 @@ export class VacayService {
if (nextYearExists) {
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = this.getPlanUsers(planId);
const users = await this.getPlanUsers(planId);
const prevYear = this.db.get<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? AND year < ? ORDER BY year DESC LIMIT 1', planId, year + 1);
for (const u of users) {
@@ -1067,7 +1086,7 @@ export class VacayService {
if (carryOverEnabled && prevYear) {
const prevConfig = this.db.get<VacayUserYear>('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, prevYear.year);
if (prevConfig) {
const used = this.usedDays(u.id, planId, prevYear.year);
const used = await this.usedDays(u.id, planId, prevYear.year);
const total = prevConfig.vacation_days + prevConfig.carried_over;
carry = Math.max(0, total - used);
}
@@ -1077,8 +1096,8 @@ export class VacayService {
}
});
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return this.listYears(planId);
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return await this.listYears(planId);
}
// -------------------------------------------------------------------------
@@ -1090,8 +1109,8 @@ export class VacayService {
* window (#737) — a shifted year spans two calendar years, so the old year prefix
* would drop the second half. For 'calendar' the range is Jan 1 – Dec 31 again.
*/
getEntries(planId: number, year: string, viewerId?: number) {
const { start, end } = this.viewerGridWindow(year, viewerId);
async getEntries(planId: number, year: string, viewerId?: number) {
const { start, end } = await this.viewerGridWindow(year, viewerId);
const entries = this.db.all(`
SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color
FROM vacay_entries e
@@ -1103,7 +1122,7 @@ export class VacayService {
return { entries, companyHolidays };
}
toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): { action?: string; fraction?: number; kind?: string; error?: string } {
async toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): Promise<{ action?: string; fraction?: number; kind?: string; error?: string }> {
const frac = normalizeFraction(fraction);
const knd = normalizeKind(kind);
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
@@ -1114,32 +1133,32 @@ export class VacayService {
// clicking a different type or fraction converts it in place (#552/#1074).
if (existing.fraction === frac && (existing.kind || 'vacation') === knd) {
this.db.run('DELETE FROM vacay_entries WHERE id = ?', existing.id);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
return { action: 'removed' };
}
// Removing a stray entry on a blocked day stays possible; keeping one
// there (converted in place) does not.
if (weekendBlocked) return { error: 'weekend_blocked' };
this.db.run('UPDATE vacay_entries SET fraction = ?, kind = ? WHERE id = ?', frac, knd, existing.id);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
return { action: 'updated', fraction: frac, kind: knd };
}
if (weekendBlocked) return { error: 'weekend_blocked' };
this.db.run('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)', planId, userId, date, '', frac, knd);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
return { action: 'added', fraction: frac, kind: knd };
}
toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } {
async toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): Promise<{ action: string }> {
const existing = this.db.get<{ id: number }>('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?', planId, date);
if (existing) {
this.db.run('DELETE FROM vacay_company_holidays WHERE id = ?', existing.id);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
return { action: 'removed' };
} else {
this.db.run('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', planId, date, note || '');
this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?', planId, date);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
return { action: 'added' };
}
}
@@ -1148,14 +1167,17 @@ export class VacayService {
// Stats
// -------------------------------------------------------------------------
getStats(planId: number, year: number) {
async getStats(planId: number, year: number) {
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = this.getPlanUsers(planId);
const users = await this.getPlanUsers(planId);
return users.map(u => {
const used = this.usedDays(u.id, planId, year);
const compUsed = this.compUsedDays(u.id, planId, year);
// `map` cannot await, and the body also writes next year's carry-over, so
// the projection runs as an explicit loop — same users, same order, same writes.
const rows = [];
for (const u of users) {
const used = await this.usedDays(u.id, planId, year);
const compUsed = await this.compUsedDays(u.id, planId, year);
const config = this.db.get<VacayUserYear>('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, year);
const vacationDays = config ? config.vacation_days : 30;
const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0;
@@ -1164,7 +1186,7 @@ export class VacayService {
const colorRow = this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', u.id, planId);
// The period this row was computed over (#737) — the UI labels the window and
// the carry-over source with it instead of assuming Jan–Dec / year − 1.
const window = this.resolveYearWindow(u.id, year);
const window = await this.resolveYearWindow(u.id, year);
const nextYearExists = this.db.get('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year + 1);
if (nextYearExists && carryOverEnabled) {
@@ -1175,32 +1197,33 @@ export class VacayService {
`, u.id, planId, year + 1, carry, carry);
}
return {
rows.push({
user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1',
year, vacation_days: vacationDays, carried_over: carriedOver,
total_available: total, used, remaining, comp_used: compUsed,
window_start: window.start, window_end: window.end,
};
});
});
}
return rows;
}
updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): void {
async updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): Promise<void> {
this.db.run(`
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0)
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
`, userId, planId, year, vacationDays);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
}
// -------------------------------------------------------------------------
// GET /plan composite
// -------------------------------------------------------------------------
getPlanData(userId: number) {
const plan = this.getActivePlan(userId);
async getPlanData(userId: number) {
const plan = await this.getActivePlan(userId);
const activePlanId = plan.id;
const users = this.getPlanUsers(activePlanId).map(u => {
const users = (await this.getPlanUsers(activePlanId)).map(u => {
const colorRow = this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', u.id, activePlanId);
return { ...u, color: colorRow?.color || '#6366f1' };
});
+2 -2
View File
@@ -184,7 +184,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
daysService,
permissionsService,
budgetService,
new VacayService(dbService, realtimeService, notificationsStub()),
new VacayService(dbService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection)),
realtimeService,
new UnsplashService(dbService, new RuntimeEnvService(), generalStorage),
generalStorage,
@@ -229,7 +229,7 @@ export async function createMcpTestRegistry(): Promise<McpRegistry> {
new AccommodationsMcp(accommodationsService, dbService, placesService, authService, guards, await createTestUnitOfWork(dbService.connection)),
new AssignmentsMcp(assignmentsService, daysService, authService, guards),
new CollabMcp(collabService, authService, addonsService, guards),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub()), authService, addonsService),
new VacayMcp(new VacayService(dbService, realtimeService, notificationsStub(), await createTestUnitOfWork(dbService.connection)), authService, addonsService),
new SchoolHolidaysMcp(new SchoolHolidaysService(dbService, await createTestUnitOfWork(dbService.connection)), guards),
new TripsMcp(tripsService, todoService, collabService, authService, calendarService, membersService, readModelService, addonsService, guards),
new TripPromptsMcp(tripsService, readModelService, packingService, addonsService),
+1 -1
View File
@@ -97,7 +97,7 @@ export async function createPluginRpcHostFactory(dbs: DatabaseService): Promise<
const packing = new PackingService(dbs, permissions, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
const files = new FilesService(dbs, permissions, realtime, new EphemeralTokenService(), generalStorage);
const collab = new CollabService(dbs, permissions, realtime, notificationsStub(), generalStorage, new RateLimitService(), await createTestUnitOfWork(dbs.connection));
const vacay = new VacayService(dbs, realtime, notificationsStub());
const vacay = new VacayService(dbs, realtime, notificationsStub(), await createTestUnitOfWork(dbs.connection));
const days = new DaysService(dbs, permissions, realtime, queryHelpers, await createTestUnitOfWork(dbs.connection));
const photoCache = new PlacePhotoCacheService(dbs, makeStorageFixture('photos/google/').storage);
const unsplash = new UnsplashService(dbs, new RuntimeEnvService(), generalStorage);
@@ -84,7 +84,7 @@ afterEach(() => { delete process.env.NODE_ENV; });
describe('AdminController users', () => {
it('lists, creates (201 + audit), maps an error', async () => {
expect(adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listUsers()).toEqual({ users: [{ id: 1 }] });
expect(await adminCtl(svc({ listUsers: vi.fn().mockReturnValue([{ id: 1 }]) } as Partial<AdminService>)).listUsers()).toEqual({ users: [{ id: 1 }] });
expect(await thrown(() => adminCtl(svc({ createUser: vi.fn().mockReturnValue({ error: 'Email taken', status: 409 }) } as Partial<AdminService>)).createUser(user, {}, req))).toEqual({ status: 409, body: { error: 'Email taken' } });
const c = adminCtl(svc({ createUser: vi.fn().mockReturnValue({ user: { id: 2 }, insertedId: 2, auditDetails: {} }) } as Partial<AdminService>));
expect(await c.createUser(user, { email: 'a@b.c' }, req)).toEqual({ user: { id: 2 } });
@@ -184,12 +184,12 @@ describe('AdminController error envelope fallbacks', () => {
describe('AdminController read-only getters', () => {
it('return service values verbatim', async () => {
expect(await adminCtl(svc({ resetUserPasskeys: vi.fn().mockReturnValue({ email: 'a@b.c', deleted: 2 }) } as Partial<AdminService>)).resetUserPasskeys(user, '4', req)).toEqual({ success: true, deleted: 2 });
expect(adminCtl(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial<AdminService>)).stats()).toEqual({ users: 3 });
expect(await adminCtl(svc({ getStats: vi.fn().mockReturnValue({ users: 3 }) } as Partial<AdminService>)).stats()).toEqual({ users: 3 });
expect(await adminCtl(svc({ getPermissions: vi.fn().mockReturnValue({ a: 1 }) } as Partial<AdminService>)).permissions()).toEqual({ a: 1 });
expect(adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
expect(await adminCtl(svc({ getAuditLog: vi.fn().mockReturnValue({ entries: [] }) } as Partial<AdminService>)).auditLog({})).toEqual({ entries: [] });
await expect(adminCtl(svc({ checkVersion: vi.fn().mockResolvedValue({ current: '1' }) } as Partial<AdminService>)).versionCheck()).resolves.toEqual({ current: '1' });
expect(await adminCtl(svc(), undefined, undefined, {}, { listInvites: vi.fn().mockReturnValue([{ id: 1 }]) }).listInvites()).toEqual({ invites: [{ id: 1 }] });
expect(adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
expect(await adminCtl(svc({ listAddons: vi.fn().mockReturnValue([{ id: 'mcp' }]) } as Partial<AdminService>)).listAddons()).toEqual({ addons: [{ id: 'mcp' }] });
expect(await adminCtl(svc(), undefined, undefined, { listAllMcpTokens: vi.fn().mockReturnValue([{ id: 1 }]) }).listMcpTokens()).toEqual({ tokens: [{ id: 1 }] });
expect(await adminCtl(svc(), undefined, undefined, {}, {}, { listAllOAuthSessions: vi.fn().mockResolvedValue([{ id: 1 }]) }).listOAuthSessions()).toEqual({ sessions: [{ id: 1 }] });
});
+58 -57
View File
@@ -112,6 +112,7 @@ beforeAll(async () => {
await makeNotificationsService(dbs, realtime),
userCleanup,
realtime,
await createTestUnitOfWork(dbs.connection),
);
});
@@ -147,10 +148,10 @@ afterAll(() => {
// ── listUsers ─────────────────────────────────────────────────────────────────
describe('listUsers', () => {
it('ADMIN-SVC-001 — returns all users with online:false', () => {
it('ADMIN-SVC-001 — returns all users with online:false', async () => {
createUser(testDb);
createUser(testDb);
const users = listUsers() as any[];
const users = (await listUsers()) as any[];
expect(users.length).toBeGreaterThanOrEqual(2);
expect(users.every((u: any) => u.online === false)).toBe(true);
});
@@ -159,38 +160,38 @@ describe('listUsers', () => {
// ── createUser ────────────────────────────────────────────────────────────────
describe('createUser (service)', () => {
it('ADMIN-SVC-002 — creates a user successfully', () => {
const result = svcCreateUser({ username: 'newuser', email: 'new@test.com', password: 'ValidPass1!' }) as any;
it('ADMIN-SVC-002 — creates a user successfully', async () => {
const result = (await svcCreateUser({ username: 'newuser', email: 'new@test.com', password: 'ValidPass1!' })) as any;
expect(result.user).toBeDefined();
expect(result.user.email).toBe('new@test.com');
});
it('ADMIN-SVC-003 — returns 400 when username is missing', () => {
const result = svcCreateUser({ username: '', email: 'x@x.com', password: 'ValidPass1!' }) as any;
it('ADMIN-SVC-003 — returns 400 when username is missing', async () => {
const result = (await svcCreateUser({ username: '', email: 'x@x.com', password: 'ValidPass1!' })) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-004 — returns 400 for invalid role', () => {
const result = svcCreateUser({ username: 'u1', email: 'u1@test.com', password: 'ValidPass1!', role: 'superuser' }) as any;
it('ADMIN-SVC-004 — returns 400 for invalid role', async () => {
const result = (await svcCreateUser({ username: 'u1', email: 'u1@test.com', password: 'ValidPass1!', role: 'superuser' })) as any;
expect(result.status).toBe(400);
expect(result.error).toMatch(/invalid role/i);
});
it('ADMIN-SVC-005 — returns 409 for duplicate username', () => {
it('ADMIN-SVC-005 — returns 409 for duplicate username', async () => {
createUser(testDb);
const { user } = createUser(testDb);
const result = svcCreateUser({ username: user.username, email: 'unique@test.com', password: 'ValidPass1!' }) as any;
const result = (await svcCreateUser({ username: user.username, email: 'unique@test.com', password: 'ValidPass1!' })) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-006 — returns 409 for duplicate email', () => {
it('ADMIN-SVC-006 — returns 409 for duplicate email', async () => {
const { user } = createUser(testDb);
const result = svcCreateUser({ username: 'uniqueuser', email: user.email, password: 'ValidPass1!' }) as any;
const result = (await svcCreateUser({ username: 'uniqueuser', email: user.email, password: 'ValidPass1!' })) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-007 — returns 400 for weak password', () => {
const result = svcCreateUser({ username: 'weakpwuser', email: 'weakpw@test.com', password: 'short' }) as any;
it('ADMIN-SVC-007 — returns 400 for weak password', async () => {
const result = (await svcCreateUser({ username: 'weakpwuser', email: 'weakpw@test.com', password: 'short' })) as any;
expect(result.status).toBe(400);
});
});
@@ -198,47 +199,47 @@ describe('createUser (service)', () => {
// ── updateUser ────────────────────────────────────────────────────────────────
describe('updateUser', () => {
it('ADMIN-SVC-008 — updates username successfully', () => {
it('ADMIN-SVC-008 — updates username successfully', async () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { username: 'updatedname' }) as any;
const result = (await updateUser(String(user.id), { username: 'updatedname' })) as any;
expect(result.user).toBeDefined();
expect(result.user.username).toBe('updatedname');
});
it('ADMIN-SVC-009 — returns 404 for non-existent user', () => {
const result = updateUser('99999', { username: 'ghost' }) as any;
it('ADMIN-SVC-009 — returns 404 for non-existent user', async () => {
const result = (await updateUser('99999', { username: 'ghost' })) as any;
expect(result.status).toBe(404);
});
it('ADMIN-SVC-010 — returns 400 for invalid role', () => {
it('ADMIN-SVC-010 — returns 400 for invalid role', async () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { role: 'superadmin' }) as any;
const result = (await updateUser(String(user.id), { role: 'superadmin' })) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-011 — returns 409 when username is taken', () => {
it('ADMIN-SVC-011 — returns 409 when username is taken', async () => {
const { user: u1 } = createUser(testDb);
const { user: u2 } = createUser(testDb);
const result = updateUser(String(u2.id), { username: u1.username }) as any;
const result = (await updateUser(String(u2.id), { username: u1.username })) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-012 — returns 409 when email is taken', () => {
it('ADMIN-SVC-012 — returns 409 when email is taken', async () => {
const { user: u1 } = createUser(testDb);
const { user: u2 } = createUser(testDb);
const result = updateUser(String(u2.id), { email: u1.email }) as any;
const result = (await updateUser(String(u2.id), { email: u1.email })) as any;
expect(result.status).toBe(409);
});
it('ADMIN-SVC-013 — returns 400 for weak password', () => {
it('ADMIN-SVC-013 — returns 400 for weak password', async () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { password: 'weak' }) as any;
const result = (await updateUser(String(user.id), { password: 'weak' })) as any;
expect(result.status).toBe(400);
});
it('ADMIN-SVC-014 — tracks changed fields in result', () => {
it('ADMIN-SVC-014 — tracks changed fields in result', async () => {
const { user } = createUser(testDb);
const result = updateUser(String(user.id), { username: 'newname', role: 'admin' }) as any;
const result = (await updateUser(String(user.id), { username: 'newname', role: 'admin' })) as any;
expect(result.changed).toContain('username');
expect(result.changed).toContain('role');
});
@@ -270,8 +271,8 @@ describe('deleteUser', () => {
// ── getStats ──────────────────────────────────────────────────────────────────
describe('getStats', () => {
it('ADMIN-SVC-018 — returns numeric counts for all stats', () => {
const stats = getStats() as any;
it('ADMIN-SVC-018 — returns numeric counts for all stats', async () => {
const stats = (await getStats()) as any;
expect(typeof stats.totalUsers).toBe('number');
expect(typeof stats.totalTrips).toBe('number');
expect(typeof stats.totalPlaces).toBe('number');
@@ -299,22 +300,22 @@ describe('Permissions', () => {
// ── getAuditLog ───────────────────────────────────────────────────────────────
describe('getAuditLog', () => {
it('ADMIN-SVC-021 — returns entries array with total', () => {
const result = getAuditLog({}) as any;
it('ADMIN-SVC-021 — returns entries array with total', async () => {
const result = (await getAuditLog({})) as any;
expect(Array.isArray(result.entries)).toBe(true);
expect(typeof result.total).toBe('number');
expect(result.limit).toBe(100);
expect(result.offset).toBe(0);
});
it('ADMIN-SVC-022 — respects limit and offset params', () => {
const result = getAuditLog({ limit: '10', offset: '0' }) as any;
it('ADMIN-SVC-022 — respects limit and offset params', async () => {
const result = (await getAuditLog({ limit: '10', offset: '0' })) as any;
expect(result.limit).toBe(10);
expect(result.offset).toBe(0);
});
it('ADMIN-SVC-023 — caps limit at 500', () => {
const result = getAuditLog({ limit: '9999' }) as any;
it('ADMIN-SVC-023 — caps limit at 500', async () => {
const result = (await getAuditLog({ limit: '9999' })) as any;
expect(result.limit).toBe(500);
});
});
@@ -322,24 +323,24 @@ describe('getAuditLog', () => {
// ── getAuditLog — JSON details parsing ───────────────────────────────────────
describe('getAuditLog — JSON details', () => {
it('ADMIN-SVC-045 — parses JSON details when present', () => {
it('ADMIN-SVC-045 — parses JSON details when present', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
user.id, 'test_action', JSON.stringify({ key: 'val' })
);
const result = getAuditLog({}) as any;
const result = (await getAuditLog({})) as any;
expect(result.entries.length).toBeGreaterThanOrEqual(1);
const entry = result.entries.find((e: any) => e.action === 'test_action');
expect(entry).toBeDefined();
expect(entry.details).toEqual({ key: 'val' });
});
it('ADMIN-SVC-046 — falls back to the raw string when details are not valid JSON', () => {
it('ADMIN-SVC-046 — falls back to the raw string when details are not valid JSON', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
user.id, 'bad_json_action', 'not-valid-json{'
);
const result = getAuditLog({}) as any;
const result = (await getAuditLog({})) as any;
const entry = result.entries.find((e: any) => e.action === 'bad_json_action');
expect(entry).toBeDefined();
// Was { _parse_error: true } before the 2026-08 quirk fix — the admin UI
@@ -477,8 +478,8 @@ describe('checkVersion', () => {
// ── listAddons ────────────────────────────────────────────────────────────────
describe('listAddons', () => {
it('ADMIN-SVC-065 — listAddons returns array containing seeded addon entries', () => {
const result = listAddons() as any[];
it('ADMIN-SVC-065 — listAddons returns array containing seeded addon entries', async () => {
const result = (await listAddons()) as any[];
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBeGreaterThan(0);
const addonIds = result.map((a: any) => a.id);
@@ -597,10 +598,10 @@ describe('version-check job', () => {
describe('admin quirk fixes (post-fold)', () => {
it('ADMIN-SVC-072 — updateUser rejects an empty username/email instead of silently no-opping', () => {
it('ADMIN-SVC-072 — updateUser rejects an empty username/email instead of silently no-opping', async () => {
const { user } = createUser(testDb);
expect(updateUser(String(user.id), { username: '' }) as any).toMatchObject({ status: 400, error: 'Username cannot be empty' });
expect(updateUser(String(user.id), { email: ' ' }) as any).toMatchObject({ status: 400, error: 'Email cannot be empty' });
expect((await updateUser(String(user.id), { username: '' })) as any).toMatchObject({ status: 400, error: 'Username cannot be empty' });
expect((await updateUser(String(user.id), { email: ' ' })) as any).toMatchObject({ status: 400, error: 'Email cannot be empty' });
// The row is untouched.
const row = testDb.prepare('SELECT username FROM users WHERE id = ?').get(user.id) as { username: string };
expect(row.username).toBe(user.username);
@@ -619,7 +620,7 @@ const mcpTokenCount = (id: number): number =>
(testDb.prepare('SELECT COUNT(*) AS n FROM mcp_tokens WHERE user_id = ?').get(id) as { n: number }).n;
describe('admin password reset revokes what an intruder already holds', () => {
it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', () => {
it('ADMIN-SVC-080 — setting a password bumps password_version, so existing cookies stop working', async () => {
// An admin sets somebody else's password for one reason: the account is
// believed compromised. Without the bump, verifyJwtAndLoadUser keeps
// accepting every cookie the intruder holds, and the one action taken to
@@ -627,26 +628,26 @@ describe('admin password reset revokes what an intruder already holds', () => {
const { user } = createUser(testDb);
const before = pv(user.id);
updateUser(String(user.id), { password: 'ANewStrongPass123!' });
await updateUser(String(user.id), { password: 'ANewStrongPass123!' });
expect(pv(user.id)).toBe(before + 1);
});
it('ADMIN-SVC-081 — and clears the MCP tokens, which the version bump does not reach', () => {
it('ADMIN-SVC-081 — and clears the MCP tokens, which the version bump does not reach', async () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
updateUser(String(user.id), { password: 'ANewStrongPass123!' });
await updateUser(String(user.id), { password: 'ANewStrongPass123!' });
expect(mcpTokenCount(user.id)).toBe(0);
});
it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', () => {
it('ADMIN-SVC-082 — renaming a user touches neither, so an ordinary edit stays ordinary', async () => {
const { user } = createUser(testDb);
const before = pv(user.id);
testDb.prepare("INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, 'hash', 'trek_ab', 'cli')").run(user.id);
updateUser(String(user.id), { username: 'renamed' });
await updateUser(String(user.id), { username: 'renamed' });
expect(pv(user.id)).toBe(before);
expect(mcpTokenCount(user.id)).toBe(1);
@@ -654,14 +655,14 @@ describe('admin password reset revokes what an intruder already holds', () => {
});
describe('resetUserMfa', () => {
it('ADMIN-SVC-083 — clears the three columns disableMfa clears, so both paths leave one state', () => {
it('ADMIN-SVC-083 — clears the three columns disableMfa clears, so both paths leave one state', async () => {
// The passkey half has existed since passkeys landed; TOTP never had an
// answer, which left "somebody on the trip lost their phone" with no way out
// short of an operator reaching into the database.
const admin = createAdmin(testDb);
const { user } = createUserWithMfa(testDb);
const result = svc.resetUserMfa(String(user.id), admin.user.id) as { success?: boolean; email?: string };
const result = (await svc.resetUserMfa(String(user.id), admin.user.id)) as { success?: boolean; email?: string };
expect(result.success).toBe(true);
expect(result.email).toBe(user.email);
@@ -673,22 +674,22 @@ describe('resetUserMfa', () => {
expect(row.mfa_backup_codes).toBeNull();
});
it('ADMIN-SVC-084 — refuses to strip the callers own second factor', () => {
it('ADMIN-SVC-084 — refuses to strip the callers own second factor', async () => {
// Reachable from a stolen admin session otherwise, and it would take the
// second factor off the very account that session came from. The
// self-service path in Settings asks for the current password.
const admin = createAdmin(testDb);
const result = svc.resetUserMfa(String(admin.user.id), admin.user.id) as { error?: string; status?: number };
const result = (await svc.resetUserMfa(String(admin.user.id), admin.user.id)) as { error?: string; status?: number };
expect(result.status).toBe(400);
expect(result.error).toMatch(/your own/i);
});
it('ADMIN-SVC-085 — 404 for a user that is not there', () => {
it('ADMIN-SVC-085 — 404 for a user that is not there', async () => {
const admin = createAdmin(testDb);
expect(svc.resetUserMfa('99999', admin.user.id) as { status?: number }).toMatchObject({ status: 404 });
expect((await svc.resetUserMfa('99999', admin.user.id)) as { status?: number }).toMatchObject({ status: 404 });
});
});
@@ -90,6 +90,7 @@ beforeAll(async () => {
await makeNotificationsService(dbs, realtime),
userCleanup,
realtime,
await createTestUnitOfWork(dbs.connection),
);
});
const checkAndNotifyVersion = () => svc.checkAndNotifyVersion();
@@ -67,8 +67,9 @@ import type { DocumentProviderRegistry } from '../../../../src/nest/doc-sync/doc
// off, so the boolean discriminant stops discriminating. The domain's own
// predicate is what every call site uses instead.
import { docFailed } from '../../../../src/nest/doc-sync/document-provider';
import { createTestUnitOfWork } from '../../../helpers/test-uow';
const svc = new DocSyncConfigService(new DatabaseService(testDb), {} as DocumentProviderRegistry);
let svc: DocSyncConfigService;
const TOKEN = 'paperless-token-1234';
@@ -105,20 +106,21 @@ function linkInput(connectionId: number, over: Partial<DocsyncLinkInput> = {}):
};
}
function link(connectionId: number, over: Partial<DocsyncLinkInput> = {}): LinkRow {
const result = svc.createLink(TRIP, OWNER, linkInput(connectionId, over));
async function link(connectionId: number, over: Partial<DocsyncLinkInput> = {}): Promise<LinkRow> {
const result = await svc.createLink(TRIP, OWNER, linkInput(connectionId, over));
if (docFailed(result)) throw new Error(`fixture link refused: ${JSON.stringify(result.error)}`);
return result.data;
}
function storedSecret(connectionId: number): string | undefined {
const row = svc.getConnection(connectionId) as ConnectionRow;
async function storedSecret(connectionId: number): Promise<string | undefined> {
const row = (await svc.getConnection(connectionId)) as ConnectionRow;
return svc.toRef(row).secrets.api_token;
}
beforeAll(() => {
beforeAll(async () => {
createTables(testDb);
runMigrations(testDb);
svc = new DocSyncConfigService(new DatabaseService(testDb), {} as DocumentProviderRegistry, await createTestUnitOfWork(testDb));
});
beforeEach(() => {
@@ -142,7 +144,7 @@ describe('upsertConnection secrets', () => {
it('keeps the stored credential when the form comes back with the field blank', async () => {
const created = await connect();
await connect({ credentials: { api_token: '' } });
expect(storedSecret(created.id)).toBe(TOKEN);
expect(await storedSecret(created.id)).toBe(TOKEN);
});
it('keeps it when the form posts back the mask it was shown', async () => {
@@ -150,19 +152,19 @@ describe('upsertConnection secrets', () => {
// form from the masked view and never has to hold the real credential.
const created = await connect();
await connect({ credentials: { api_token: DOCSYNC_SECRET_MASK } });
expect(storedSecret(created.id)).toBe(TOKEN);
expect(await storedSecret(created.id)).toBe(TOKEN);
});
it('keeps it when the field is missing from the payload altogether', async () => {
const created = await connect();
await connect({ credentials: {} });
expect(storedSecret(created.id)).toBe(TOKEN);
expect(await storedSecret(created.id)).toBe(TOKEN);
});
it('replaces it when a new credential actually arrives', async () => {
const created = await connect();
await connect({ credentials: { api_token: 'rotated-token' } });
expect(storedSecret(created.id)).toBe('rotated-token');
expect(await storedSecret(created.id)).toBe('rotated-token');
});
it('updates the trip\'s connection in place rather than adding a second one', async () => {
@@ -194,10 +196,10 @@ describe('upsertConnection secrets', () => {
expect(docFailed(result)).toBe(true);
expect(docFailed(result) && result.error.code).toBe('unauthorized');
const row = svc.getConnection(created.id) as ConnectionRow;
const row = await svc.getConnection(created.id) as ConnectionRow;
expect(row.base_url).toBe('https://paperless.example.com');
expect(row.owner_user_id).toBe(OWNER);
expect(storedSecret(created.id)).toBe(TOKEN);
expect(await storedSecret(created.id)).toBe(TOKEN);
});
it('refuses the mask as well, since the mask is not the credential', async () => {
@@ -214,22 +216,22 @@ describe('upsertConnection secrets', () => {
const moved = await connect({ baseUrl: 'https://paperless.example.net', credentials: { api_token: 'new-token' } });
expect(moved.id).toBe(created.id);
expect(moved.base_url).toBe('https://paperless.example.net');
expect(storedSecret(created.id)).toBe('new-token');
expect(await storedSecret(created.id)).toBe('new-token');
});
it('drops a secret the provider earned for the old address, even when the form is complete', async () => {
const conn = await connect({ providerId: 'synologydrive', baseUrl: 'https://nas.example.com:5001', credentials: { username: 'anna', password: 'nas-pw' } });
svc.saveEarnedSecret(conn.id, 'device_token', 'a1b2c3:DEVICE-7');
await svc.saveEarnedSecret(conn.id, 'device_token', 'a1b2c3:DEVICE-7');
await connect({ providerId: 'synologydrive', baseUrl: 'https://nas.example.net:5001', credentials: { username: 'anna', password: 'nas-pw' } });
expect(svc.toRef(svc.getConnection(conn.id) as ConnectionRow).secrets).toEqual({ password: 'nas-pw' });
expect(svc.toRef((await svc.getConnection(conn.id)) as ConnectionRow).secrets).toEqual({ password: 'nas-pw' });
});
it('reads a path or a trailing slash on the same server as the same address', async () => {
const created = await connect();
await connect({ baseUrl: 'https://paperless.example.com/paperless/', credentials: {} });
expect(storedSecret(created.id)).toBe(TOKEN);
expect(await storedSecret(created.id)).toBe(TOKEN);
});
it('reads another port or scheme as another server', async () => {
@@ -251,13 +253,13 @@ describe('upsertConnection secrets', () => {
it('stays with the person whose secret is stored when somebody else saves the form blank', async () => {
const created = await connect();
await connect({ credentials: {} }, MEMBER);
expect((svc.getConnection(created.id) as ConnectionRow).owner_user_id).toBe(OWNER);
expect((await svc.getConnection(created.id) as ConnectionRow).owner_user_id).toBe(OWNER);
});
it('moves to the person who typed a new secret in', async () => {
const created = await connect();
await connect({ credentials: { api_token: 'members-own-token' } }, MEMBER);
expect((svc.getConnection(created.id) as ConnectionRow).owner_user_id).toBe(MEMBER);
expect((await svc.getConnection(created.id) as ConnectionRow).owner_user_id).toBe(MEMBER);
});
it('stays put when only an optional secret is typed, since that runs against the stored password', async () => {
@@ -265,7 +267,7 @@ describe('upsertConnection secrets', () => {
const created = await connect({ ...nas, credentials: { username: 'anna', password: 'nas-pw' } });
await connect({ ...nas, credentials: { username: 'anna', otp_code: '123456' } }, MEMBER);
const row = svc.getConnection(created.id) as ConnectionRow;
const row = await svc.getConnection(created.id) as ConnectionRow;
expect(row.owner_user_id).toBe(OWNER);
expect(svc.toRef(row).secrets).toEqual({ password: 'nas-pw', otp_code: '123456' });
});
@@ -286,7 +288,7 @@ describe('upsertConnection secrets', () => {
credentials: { login_name: 'anna' },
allowInsecureTls: false,
});
const row = svc.listConnections(TRIP).find((c) => c.provider_id === 'nextcloud') as ConnectionRow;
const row = (await svc.listConnections(TRIP)).find((c) => c.provider_id === 'nextcloud') as ConnectionRow;
expect(svc.toRef(row).settings).toMatchObject({ login_name: 'anna', base_path: '/TREK' });
expect(svc.toRef(row).secrets.app_password).toBe('app-pw');
});
@@ -295,7 +297,7 @@ describe('upsertConnection secrets', () => {
describe('publicConnection', () => {
it('reports that a secret is set without ever carrying its value', async () => {
const created = await connect();
const view = svc.publicConnection(svc.getConnection(created.id) as ConnectionRow);
const view = await svc.publicConnection((await svc.getConnection(created.id)) as ConnectionRow);
expect(view.secrets).toEqual({ api_token: DOCSYNC_SECRET_MASK });
expect(JSON.stringify(view)).not.toContain(TOKEN);
});
@@ -310,14 +312,14 @@ describe('publicConnection', () => {
allowInsecureTls: false,
});
expect(created.success).toBe(true);
const row = svc.listConnections(TRIP).find((c) => c.provider_id === 'synologydrive') as ConnectionRow;
expect(svc.publicConnection(row).secrets).toEqual({ password: DOCSYNC_SECRET_MASK });
const row = (await svc.listConnections(TRIP)).find((c) => c.provider_id === 'synologydrive') as ConnectionRow;
expect((await svc.publicConnection(row)).secrets).toEqual({ password: DOCSYNC_SECRET_MASK });
});
it('reads a capabilities blob left behind by another build as unknown, not as a crash', async () => {
const created = await connect();
testDb.prepare('UPDATE document_connections SET capabilities = ? WHERE id = ?').run('{not json', created.id);
const view = svc.publicConnection(svc.getConnection(created.id) as ConnectionRow);
const view = await svc.publicConnection((await svc.getConnection(created.id)) as ConnectionRow);
expect(view.capabilities).toBeNull();
});
});
@@ -330,22 +332,22 @@ describe('a secret the provider earned itself', () => {
return connect({ providerId: 'synologydrive', baseUrl: 'https://nas.example.com:5001', credentials });
}
function secretsOf(connectionId: number): Readonly<Record<string, string>> {
return svc.toRef(svc.getConnection(connectionId) as ConnectionRow).secrets;
async function secretsOf(connectionId: number): Promise<Readonly<Record<string, string>>> {
return svc.toRef((await svc.getConnection(connectionId)) as ConnectionRow).secrets;
}
it('is stored through the ref the adapter was handed, encrypted with the form\'s secrets', async () => {
const conn = await nas();
svc.toRef(conn).saveSecret!('device_token', EARNED);
await svc.toRef(conn).saveSecret!('device_token', EARNED);
expect(secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456', device_token: EARNED });
expect((svc.getConnection(conn.id) as ConnectionRow).secrets).not.toContain('DEVICE-7');
expect(await secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456', device_token: EARNED });
expect((await svc.getConnection(conn.id) as ConnectionRow).secrets).not.toContain('DEVICE-7');
});
it('never reaches a client, not even as a mask', async () => {
const conn = await nas();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
const view = svc.publicConnection(svc.getConnection(conn.id) as ConnectionRow);
await svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
const view = await svc.publicConnection((await svc.getConnection(conn.id)) as ConnectionRow);
expect(view.secrets).toEqual({ password: DOCSYNC_SECRET_MASK, otp_code: DOCSYNC_SECRET_MASK });
expect(JSON.stringify(view)).not.toContain('DEVICE-7');
@@ -354,48 +356,48 @@ describe('a secret the provider earned itself', () => {
it('survives a form edit, whatever the form sends for the secrets it does show', async () => {
const conn = await nas();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await nas({ username: 'anna', password: 'rotated', otp_code: '' });
await nas({ username: 'anna', password: DOCSYNC_SECRET_MASK });
expect(secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED });
expect(await secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED });
});
it('cannot be planted or overwritten through the form', async () => {
const conn = await nas();
await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' });
expect(secretsOf(conn.id).device_token).toBeUndefined();
expect((await secretsOf(conn.id)).device_token).toBeUndefined();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await nas({ username: 'anna', device_token: 'a1b2c3:PLANTED' });
expect(secretsOf(conn.id).device_token).toBe(EARNED);
expect(svc.toRef(svc.getConnection(conn.id) as ConnectionRow).settings).not.toHaveProperty('device_token');
expect((await secretsOf(conn.id)).device_token).toBe(EARNED);
expect(svc.toRef((await svc.getConnection(conn.id)) as ConnectionRow).settings).not.toHaveProperty('device_token');
});
it('is written against the row as it is now, so a run holding an old ref cannot undo a password edit', async () => {
const conn = await nas();
const ref = svc.toRef(conn);
await nas({ username: 'anna', password: 'rotated' });
ref.saveSecret!('device_token', EARNED);
await ref.saveSecret!('device_token', EARNED);
expect(secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED });
expect(await secretsOf(conn.id)).toMatchObject({ password: 'rotated', device_token: EARNED });
});
it('is dropped with null, and the form\'s secrets stay', async () => {
const conn = await nas();
svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
svc.saveEarnedSecret(conn.id, 'device_token', null);
await svc.saveEarnedSecret(conn.id, 'device_token', EARNED);
await svc.saveEarnedSecret(conn.id, 'device_token', null);
expect(secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456' });
expect(await secretsOf(conn.id)).toEqual({ password: 'nas-pw', otp_code: '123456' });
});
it('brings back no connection that was deleted while a run still held its ref', async () => {
const conn = await nas();
const ref = svc.toRef(conn);
svc.deleteConnection(conn.id);
await svc.deleteConnection(conn.id);
ref.saveSecret!('device_token', EARNED);
expect(svc.getConnection(conn.id)).toBeUndefined();
await ref.saveSecret!('device_token', EARNED);
expect(await svc.getConnection(conn.id)).toBeUndefined();
});
});
@@ -414,7 +416,7 @@ describe('validateBaseUrl', () => {
const result = await svc.upsertConnection(TRIP, OWNER, connectionInput({ baseUrl: 'https://nope.invalid' }));
expect(result.success).toBe(false);
expect(result.success === false && result.error.code).toBe('ssrf_blocked');
expect(svc.listConnections(TRIP)).toHaveLength(0);
expect(await svc.listConnections(TRIP)).toHaveLength(0);
});
it('strips trailing slashes so the adapter can append its own path', async () => {
@@ -434,7 +436,7 @@ describe('upsertConnection required fields', () => {
const result = await svc.upsertConnection(TRIP, OWNER, connectionInput({ credentials: {} }));
expect(result.success).toBe(false);
expect(result.success === false && result.error.detail).toContain('api_token');
expect(svc.listConnections(TRIP)).toHaveLength(0);
expect(await svc.listConnections(TRIP)).toHaveLength(0);
});
it('refuses a missing required field that is not a secret either', async () => {
@@ -462,12 +464,12 @@ describe('upsertConnection required fields', () => {
});
describe('enabledProviderIds', () => {
it('names only the providers an admin switched on, in the order the shelf shows them', () => {
it('names only the providers an admin switched on, in the order the shelf shows them', async () => {
// Every document provider ships OFF, so an empty answer on a fresh install
// is the correct one and a non-empty one would be a policy change.
expect(svc.enabledProviderIds()).toEqual([]);
expect(await svc.enabledProviderIds()).toEqual([]);
testDb.prepare("UPDATE document_providers SET enabled = 1 WHERE id IN ('nextcloud', 'paperless')").run();
expect(svc.enabledProviderIds()).toEqual(['paperless', 'nextcloud']);
expect(await svc.enabledProviderIds()).toEqual(['paperless', 'nextcloud']);
});
});
@@ -477,10 +479,10 @@ describe('recordProbe', () => {
// instance can accept uploads; the capabilities describe the provider, not
// the state of the credential.
const conn = await connect();
svc.recordProbe(conn.id, 'ok', null, { push: 'multipart' });
svc.recordProbe(conn.id, 'failed', '401 Unauthorized', null);
await svc.recordProbe(conn.id, 'ok', null, { push: 'multipart' });
await svc.recordProbe(conn.id, 'failed', '401 Unauthorized', null);
const view = svc.publicConnection(svc.getConnection(conn.id) as ConnectionRow);
const view = await svc.publicConnection((await svc.getConnection(conn.id)) as ConnectionRow);
expect(view).toMatchObject({
lastProbeState: 'failed',
lastProbeError: '401 Unauthorized',
@@ -497,16 +499,16 @@ describe('deleteConnection', () => {
baseUrl: 'https://cloud.example.com',
credentials: { login_name: 'anna', app_password: 'app-pw' },
});
const doomedLink = link(doomed.id, { scopeKey: 'tag:1' });
const keptLink = link(kept.id, { scopeKey: 'folder:1' });
const doomedLink = await link(doomed.id, { scopeKey: 'tag:1' });
const keptLink = await link(kept.id, { scopeKey: 'folder:1' });
testDb
.prepare('INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)')
.run(doomedLink.id, TRIP, 'uid-a');
svc.deleteConnection(doomed.id);
await svc.deleteConnection(doomed.id);
expect(svc.getConnection(doomed.id)).toBeUndefined();
expect(svc.listLinks(TRIP).map((l) => l.id)).toEqual([keptLink.id]);
expect(await svc.getConnection(doomed.id)).toBeUndefined();
expect((await svc.listLinks(TRIP)).map((l) => l.id)).toEqual([keptLink.id]);
expect(testDb.prepare('SELECT COUNT(*) AS n FROM document_sync_items').get()).toEqual({ n: 0 });
});
});
@@ -515,7 +517,7 @@ describe('toRef', () => {
it('names the row and not only its id, which a backup restored in place hands out again', async () => {
const first = await connect();
testDb.prepare("UPDATE document_connections SET created_at = '2026-09-01 08:00:00' WHERE id = ?").run(first.id);
const before = svc.toRef(svc.getConnection(first.id) as ConnectionRow);
const before = svc.toRef(await svc.getConnection(first.id) as ConnectionRow);
// What the restore of an older backup does to this table: the row is gone
// and the id sequence is back where it stood before the row was made.
@@ -532,8 +534,8 @@ describe('toRef', () => {
describe('updateLink', () => {
it('changes only the fields the patch names', async () => {
const conn = await connect();
const created = link(conn.id, { direction: 'both', conflictPolicy: 'manual' });
const patched = svc.updateLink(created.id, { direction: 'pull', syncEnabled: false });
const created = await link(conn.id, { direction: 'both', conflictPolicy: 'manual' });
const patched = await svc.updateLink(created.id, { direction: 'pull', syncEnabled: false });
expect(patched).toMatchObject({
direction: 'pull',
sync_enabled: 0,
@@ -544,8 +546,8 @@ describe('updateLink', () => {
it('hands back the binding untouched when the patch is empty', async () => {
const conn = await connect();
const created = link(conn.id);
expect(svc.updateLink(created.id, {})).toEqual(created);
const created = await link(conn.id);
expect(await svc.updateLink(created.id, {})).toEqual(created);
});
});
@@ -554,7 +556,7 @@ describe('toScopeRef', () => {
// The adapters take a ref object rather than positional arguments because
// an id and a path swapped compile cleanly and then read the wrong folder.
const conn = await connect();
const created = svc.createLink(
const created = await svc.createLink(
TRIP,
OWNER,
linkInput(conn.id, { scopeKey: 'fileid:437', remoteRootId: '437', remoteRootPath: '/TREK/japan' }),
@@ -563,7 +565,7 @@ describe('toScopeRef', () => {
const row = created.success ? created.data : ({} as LinkRow);
testDb.prepare('UPDATE trip_document_links SET remote_cursor = ? WHERE id = ?').run('etag-9', row.id);
expect(svc.toScopeRef(svc.getLink(row.id) as LinkRow)).toEqual({
expect(svc.toScopeRef(await svc.getLink(row.id) as LinkRow)).toEqual({
linkId: row.id,
tripId: TRIP,
scopeKey: 'fileid:437',
@@ -577,37 +579,37 @@ describe('toScopeRef', () => {
describe('createLink', () => {
it('returns the binding that already exists rather than making a second one', async () => {
const conn = await connect();
const first = link(conn.id);
const again = svc.createLink(TRIP, MEMBER, linkInput(conn.id, { remoteLabel: 'renamed' }));
const first = await link(conn.id);
const again = await svc.createLink(TRIP, MEMBER, linkInput(conn.id, { remoteLabel: 'renamed' }));
expect(again.success && again.data.id).toBe(first.id);
expect(svc.listLinks(TRIP)).toHaveLength(1);
expect(await svc.listLinks(TRIP)).toHaveLength(1);
});
it('binds a second scope of the same connection separately', async () => {
const conn = await connect();
const first = link(conn.id, { scopeKey: 'tag:1' });
const second = link(conn.id, { scopeKey: 'tag:2' });
const first = await link(conn.id, { scopeKey: 'tag:1' });
const second = await link(conn.id, { scopeKey: 'tag:2' });
expect(second.id).not.toBe(first.id);
expect(svc.listLinks(TRIP)).toHaveLength(2);
expect(await svc.listLinks(TRIP)).toHaveLength(2);
});
it('refuses a connection that belongs to somebody else\'s trip', async () => {
const otherTrip = createTrip(testDb, MEMBER, { title: 'Not yours' }).id;
const conn = await connect();
const result = svc.createLink(otherTrip, MEMBER, linkInput(conn.id));
const result = await svc.createLink(otherTrip, MEMBER, linkInput(conn.id));
expect(result.success).toBe(false);
expect(result.success === false && result.error.code).toBe('not_found');
expect(svc.listLinks(otherTrip)).toHaveLength(0);
expect(await svc.listLinks(otherTrip)).toHaveLength(0);
});
it('refuses a connection id that does not exist at all', async () => {
const result = svc.createLink(TRIP, OWNER, linkInput(9999));
const result = await svc.createLink(TRIP, OWNER, linkInput(9999));
expect(result.success).toBe(false);
});
it('stores the anchor the picker sent back with the scope key', async () => {
const conn = await connect();
const result = svc.createLink(
const result = await svc.createLink(
TRIP,
OWNER,
linkInput(conn.id, { remoteRootId: '17', remoteRootPath: '/TREK/japan', remoteLabel: 'Japan 2026' }),
@@ -623,7 +625,7 @@ describe('createLink', () => {
it('stores NULL for an anchor field the client left out', async () => {
// Synology binds by path alone, so its options carry no root id at all.
const conn = await connect();
const result = svc.createLink(TRIP, OWNER, linkInput(conn.id));
const result = await svc.createLink(TRIP, OWNER, linkInput(conn.id));
expect(result.success && result.data).toMatchObject({ remote_root_id: null, remote_root_path: null });
});
@@ -631,15 +633,15 @@ describe('createLink', () => {
// A leaked webhook URL has to identify exactly one binding, so revoking one
// cannot be used to poke another.
const conn = await connect();
const first = link(conn.id, { scopeKey: 'tag:1' });
const second = link(conn.id, { scopeKey: 'tag:2' });
const first = await link(conn.id, { scopeKey: 'tag:1' });
const second = await link(conn.id, { scopeKey: 'tag:2' });
expect(first.webhook_token).toBeTruthy();
expect(second.webhook_token).not.toBe(first.webhook_token);
expect(svc.getLinkByToken(first.webhook_token as string)?.id).toBe(first.id);
expect((await svc.getLinkByToken(first.webhook_token as string))?.id).toBe(first.id);
const secret = svc.webhookSecret(first);
expect(secret).toBeTruthy();
const view = svc.publicLink(first, 'https://trek.example.com');
const view = await svc.publicLink(first, 'https://trek.example.com');
expect(view.webhookSecret).toBe(DOCSYNC_SECRET_MASK);
expect(JSON.stringify(view)).not.toContain(secret);
});
@@ -650,24 +652,24 @@ describe('publicLink', () => {
// The client only learns names for the providers that are on, so this is
// the one place a binding left behind can still get its name from.
const conn = await connect();
const bound = link(conn.id);
const bound = await link(conn.id);
testDb.prepare("UPDATE document_providers SET enabled = 0 WHERE id = 'paperless'").run();
expect(svc.publicLink(bound, null)).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' });
expect(await svc.publicLink(bound, null)).toMatchObject({ providerId: 'paperless', providerName: 'Paperless-ngx' });
});
it('falls back to the provider id for a provider this build does not know', async () => {
const conn = await connect();
const bound = link(conn.id);
expect(svc.publicLink({ ...bound, provider_id: 'dropbox' }, null).providerName).toBe('dropbox');
const bound = await link(conn.id);
expect((await svc.publicLink({ ...bound, provider_id: 'dropbox' }, null)).providerName).toBe('dropbox');
});
});
describe('deleteLink', () => {
it('takes the pairing rows with it and leaves the other binding\'s alone', async () => {
const conn = await connect();
const doomed = link(conn.id, { scopeKey: 'tag:1' });
const kept = link(conn.id, { scopeKey: 'tag:2' });
const doomed = await link(conn.id, { scopeKey: 'tag:1' });
const kept = await link(conn.id, { scopeKey: 'tag:2' });
const insert = testDb.prepare(
'INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid) VALUES (?, ?, ?)',
);
@@ -675,18 +677,18 @@ describe('deleteLink', () => {
insert.run(doomed.id, TRIP, 'uid-b');
insert.run(kept.id, TRIP, 'uid-c');
svc.deleteLink(doomed.id);
await svc.deleteLink(doomed.id);
expect(svc.getLink(doomed.id)).toBeUndefined();
expect(svc.getLink(kept.id)).toBeDefined();
expect(await svc.getLink(doomed.id)).toBeUndefined();
expect(await svc.getLink(kept.id)).toBeDefined();
const rows = testDb.prepare('SELECT link_id FROM document_sync_items').all() as { link_id: number }[];
expect(rows.map((r) => r.link_id)).toEqual([kept.id]);
});
it('leaves the connection behind, so unbinding one scope does not log the trip out', async () => {
const conn = await connect();
svc.deleteLink(link(conn.id).id);
expect(svc.getConnection(conn.id)).toBeDefined();
await svc.deleteLink((await link(conn.id)).id);
expect(await svc.getConnection(conn.id)).toBeDefined();
});
});
@@ -695,15 +697,15 @@ describe('markOrphanedLinks', () => {
const credentials =
providerId === 'paperless' ? { api_token: TOKEN } : { login_name: 'anna', app_password: 'app-pw' };
const conn = await connect({ providerId, credentials, baseUrl: `https://${providerId}.example.com` }, ownerUserId);
return link(conn.id, { scopeKey: `scope:${providerId}` });
return await link(conn.id, { scopeKey: `scope:${providerId}` });
}
it('disables the binding whose credential owner left the trip', async () => {
const orphan = await bind(MEMBER, 'paperless');
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(svc.markOrphanedLinks()).toBe(1);
expect(svc.getLink(orphan.id)).toMatchObject({ last_sync_state: 'orphaned', sync_enabled: 0 });
expect(await svc.markOrphanedLinks()).toBe(1);
expect(await svc.getLink(orphan.id)).toMatchObject({ last_sync_state: 'orphaned', sync_enabled: 0 });
});
it('counts the trip owner as being on the trip, although no membership row says so', async () => {
@@ -711,8 +713,8 @@ describe('markOrphanedLinks', () => {
// owner would orphan every binding the trip admin created, which is most
// of them.
const mine = await bind(OWNER, 'paperless');
expect(svc.markOrphanedLinks()).toBe(0);
expect(svc.getLink(mine.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 });
expect(await svc.markOrphanedLinks()).toBe(0);
expect(await svc.getLink(mine.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 });
});
it('leaves a still-valid binding alone while orphaning the one beside it', async () => {
@@ -720,25 +722,25 @@ describe('markOrphanedLinks', () => {
const kept = await bind(OWNER, 'nextcloud');
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(svc.markOrphanedLinks()).toBe(1);
expect(svc.getLink(orphan.id)?.last_sync_state).toBe('orphaned');
expect(svc.getLink(kept.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 });
expect(await svc.markOrphanedLinks()).toBe(1);
expect((await svc.getLink(orphan.id))?.last_sync_state).toBe('orphaned');
expect(await svc.getLink(kept.id)).toMatchObject({ last_sync_state: 'never', sync_enabled: 1 });
});
it('counts nothing on a second sweep, so the job stays quiet after the first one', async () => {
await bind(MEMBER, 'paperless');
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(svc.markOrphanedLinks()).toBe(1);
expect(svc.markOrphanedLinks()).toBe(0);
expect(await svc.markOrphanedLinks()).toBe(1);
expect(await svc.markOrphanedLinks()).toBe(0);
});
it('does not touch a binding the user had already switched off', async () => {
const paused = await bind(MEMBER, 'paperless');
svc.updateLink(paused.id, { syncEnabled: false });
await svc.updateLink(paused.id, { syncEnabled: false });
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(svc.markOrphanedLinks()).toBe(0);
expect(svc.getLink(paused.id)?.last_sync_state).toBe('never');
expect(await svc.markOrphanedLinks()).toBe(0);
expect((await svc.getLink(paused.id))?.last_sync_state).toBe('never');
});
});
@@ -749,39 +751,39 @@ describe('isOrphaned', () => {
it('answers yes for a paused binding whose owner left, which the sweep never marks', async () => {
const paused = await bind(MEMBER);
svc.updateLink(paused.id, { syncEnabled: false });
await svc.updateLink(paused.id, { syncEnabled: false });
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
expect(svc.markOrphanedLinks()).toBe(0);
expect(svc.isOrphaned(svc.getLink(paused.id)!)).toBe(true);
expect(await svc.markOrphanedLinks()).toBe(0);
expect(await svc.isOrphaned((await svc.getLink(paused.id))!)).toBe(true);
});
it('answers yes for a marked binding, and no while the owner is on the trip', async () => {
const kept = await bind(OWNER);
expect(svc.isOrphaned(kept)).toBe(false);
expect(await svc.isOrphaned(kept)).toBe(false);
testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(kept.id);
expect(svc.isOrphaned(svc.getLink(kept.id)!)).toBe(true);
expect(await svc.isOrphaned((await svc.getLink(kept.id))!)).toBe(true);
});
it('leaves a connection that is gone to the run, which reports it as not found', async () => {
const bound = await bind(OWNER);
expect(svc.isOrphaned({ ...bound, connection_id: 999999 })).toBe(false);
expect(await svc.isOrphaned({ ...bound, connection_id: 999999 })).toBe(false);
});
});
describe('switching an orphaned binding back on', () => {
async function orphan(): Promise<LinkRow> {
const bound = link((await connect({}, MEMBER)).id);
const bound = await link((await connect({}, MEMBER)).id);
testDb.prepare('DELETE FROM trip_members WHERE trip_id = ? AND user_id = ?').run(TRIP, MEMBER);
svc.markOrphanedLinks();
await svc.markOrphanedLinks();
return bound;
}
it('keeps it off while its owner is still gone, and changes the rest of the patch', async () => {
const bound = await orphan();
const res = svc.updateLink(bound.id, { syncEnabled: true, direction: 'pull' });
const res = await svc.updateLink(bound.id, { syncEnabled: true, direction: 'pull' });
expect(res).toMatchObject({ sync_enabled: 0, last_sync_state: 'orphaned', direction: 'pull' });
});
@@ -790,9 +792,9 @@ describe('switching an orphaned binding back on', () => {
const bound = await orphan();
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(TRIP, MEMBER);
const res = svc.updateLink(bound.id, { syncEnabled: true });
const res = await svc.updateLink(bound.id, { syncEnabled: true });
expect(res).toMatchObject({ sync_enabled: 1, last_sync_state: 'never' });
expect(svc.isOrphaned(res!)).toBe(false);
expect(await svc.isOrphaned(res!)).toBe(false);
});
});
@@ -78,6 +78,7 @@ import type {
DocsyncLinkDto,
} from '../../../../src/nest/doc-sync/doc-sync.dto';
import type { User } from '../../../../src/types';
import { createTestUnitOfWork } from '../../../helpers/test-uow';
const CAPS = {
push: 'webhook-self-registered' as const,
@@ -124,9 +125,11 @@ const sync = {
const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]);
const dbs = new DatabaseService(testDb);
const config = new DocSyncConfigService(dbs, registry);
const realtime = { broadcast: vi.fn() };
const controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, dbs, realtime as unknown as RealtimeService);
// Built in beforeAll: DocSyncConfigService now takes a UnitOfWork, and
// createTestUnitOfWork is async — module-scope construction cannot await it.
let config: DocSyncConfigService;
let controller: DocSyncController;
/** Only what publicOrigin reads: header lookup plus the connection's own scheme. */
function makeReq(headers: Record<string, string> = {}, protocol = 'http'): Request {
@@ -178,7 +181,9 @@ let admin: User;
let tripId: number;
let otherTripId: number;
beforeAll(() => {
beforeAll(async () => {
config = new DocSyncConfigService(dbs, registry, await createTestUnitOfWork(testDb));
controller = new DocSyncController(config, sync as unknown as DocSyncService, registry, dbs, realtime as unknown as RealtimeService);
createTables(testDb);
runMigrations(testDb);
const o = createUser(testDb, { username: 'owner', email: 'owner@test.local' }).user;
@@ -233,8 +238,8 @@ describe('who may change a trip binding', () => {
});
describe('providers', () => {
it('reports an enabled provider with no registered adapter as unavailable instead of hiding it', () => {
const rows = controller.providers() as Array<{ id: string; available: boolean }>;
it('reports an enabled provider with no registered adapter as unavailable instead of hiding it', async () => {
const rows = (await controller.providers()) as Array<{ id: string; available: boolean }>;
const byId = Object.fromEntries(rows.map((r) => [r.id, r.available]));
// papra is switched on in the DB, but no adapter is registered in this suite.
expect(byId.papra).toBe(false);
@@ -242,8 +247,8 @@ describe('providers', () => {
expect(byId.nextcloud).toBe(true);
});
it('hands the form flags to the client as booleans rather than the 0/1 the column stores', () => {
const rows = controller.providers() as Array<{ id: string; fields: Array<{ field_key: string; secret: boolean; required: boolean }> }>;
it('hands the form flags to the client as booleans rather than the 0/1 the column stores', async () => {
const rows = (await controller.providers()) as Array<{ id: string; fields: Array<{ field_key: string; secret: boolean; required: boolean }> }>;
const fields = rows.find((r) => r.id === 'paperless')!.fields;
const token = fields.find((f) => f.field_key === 'api_token')!;
expect(token.secret).toBe(true);
@@ -262,7 +267,7 @@ describe('storing a connection', () => {
expect(res.secrets.api_token).not.toContain('super-secret');
const listed = controller.listConnections(String(tripId)) as Array<{ id: number; secrets: Record<string, string> }>;
const listed = (await controller.listConnections(String(tripId))) as Array<{ id: number; secrets: Record<string, string> }>;
expect(listed).toHaveLength(1);
expect(listed[0].id).toBe(res.id);
// The list is what the form renders from, so the secret must not survive
@@ -273,7 +278,7 @@ describe('storing a connection', () => {
it('refuses a base URL the SSRF guard turns down', async () => {
const err = await thrown(() => controller.upsertConnection(String(tripId), owner, connBody({ baseUrl: 'https://blocked.invalid' })));
expect(err.getStatus()).toBe(400);
expect(config.listConnections(tripId)).toHaveLength(0);
expect(await config.listConnections(tripId)).toHaveLength(0);
});
it('refuses to point the stored secret at another server when the form leaves it blank', async () => {
@@ -285,8 +290,8 @@ describe('storing a connection', () => {
controller.upsertConnection(String(tripId), admin, connBody({ baseUrl: 'https://elsewhere.example', credentials: {} })),
);
expect(err.getStatus()).toBe(400);
expect(config.getConnection(conn.id)!.base_url).toBe('https://paperless.example.com');
expect(config.getConnection(conn.id)!.owner_user_id).toBe(Number(owner.id));
expect((await config.getConnection(conn.id))!.base_url).toBe('https://paperless.example.com');
expect((await config.getConnection(conn.id))!.owner_user_id).toBe(Number(owner.id));
});
it('refuses a provider the instance admin has not switched on, and names it', async () => {
@@ -372,14 +377,14 @@ describe('probing form values', () => {
it('writes a successful probe onto the stored connection, so the status panel has something to show', async () => {
const conn = await storedPaperless();
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
const row = config.getConnection(conn.id)!;
const row = (await config.getConnection(conn.id))!;
expect(row.last_probe_state).toBe('ok');
expect(JSON.parse(row.capabilities!)).toMatchObject({ push: 'webhook-self-registered' });
});
it('leaves no probe record behind when the form values belong to no stored connection yet', async () => {
await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: 'first-try' } }) as DocsyncConnectionTestDto);
expect(config.listConnections(tripId)).toHaveLength(0);
expect(await config.listConnections(tripId)).toHaveLength(0);
expect(paperless.probe.mock.calls[0][0]).toMatchObject({ connectionId: 0, secrets: { api_token: 'first-try' } });
});
});
@@ -395,9 +400,9 @@ describe('a secret the adapter earned itself', () => {
credentials: { username: 'anna', password: 'nas-pw' },
});
const created = (await controller.upsertConnection(String(tripId), owner, nas)) as { id: number };
config.saveEarnedSecret(created.id, 'device_token', EARNED);
await config.saveEarnedSecret(created.id, 'device_token', EARNED);
const listed = JSON.stringify(controller.listConnections(String(tripId)));
const listed = JSON.stringify(await controller.listConnections(String(tripId)));
const edited = JSON.stringify(
await controller.upsertConnection(String(tripId), owner, { ...nas, credentials: { username: 'anna', password: 'rotated' } }),
);
@@ -405,12 +410,12 @@ describe('a secret the adapter earned itself', () => {
expect(payload).not.toContain('DEVICE-SECRET');
expect(payload).not.toContain('device_token');
}
expect(config.toRef(config.getConnection(created.id)!).secrets.device_token).toBe(EARNED);
expect(config.toRef((await config.getConnection(created.id))!).secrets.device_token).toBe(EARNED);
});
it('reaches a probe of the saved form, which gets no way to write', async () => {
const conn = await storedPaperless();
config.saveEarnedSecret(conn.id, 'device_token', EARNED);
await config.saveEarnedSecret(conn.id, 'device_token', EARNED);
const res = await controller.testConnection(String(tripId), owner, connBody({ credentials: { api_token: '' } }) as DocsyncConnectionTestDto);
const ref = paperless.probe.mock.calls[0][0];
@@ -446,7 +451,7 @@ describe('removing a connection', () => {
);
await expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).resolves.toEqual({ success: true });
expect(config.getConnection(conn.id)).toBeUndefined();
expect(await config.getConnection(conn.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
});
@@ -469,8 +474,8 @@ describe('removing a connection', () => {
expect(paperless.unregisterWebhook.mock.calls.map((c) => c[1]).sort()).toEqual(['sub-7', 'sub-8']);
expect(paperless.unregisterWebhook.mock.calls[0][0]).toMatchObject({ connectionId: conn.id, secrets: { api_token: 'stored-token' } });
expect(config.getConnection(conn.id)).toBeUndefined();
expect(config.getLink(first.id)).toBeUndefined();
expect(await config.getConnection(conn.id)).toBeUndefined();
expect(await config.getLink(first.id)).toBeUndefined();
});
it('still unbinds when the provider refuses to take a subscription down', async () => {
@@ -479,14 +484,14 @@ describe('removing a connection', () => {
paperless.unregisterWebhook.mockResolvedValueOnce({ success: false, error: { code: 'unreachable' } } as never);
await expect(controller.deleteConnection(String(tripId), String(conn.id), owner)).resolves.toEqual({ success: true });
expect(config.getConnection(conn.id)).toBeUndefined();
expect(await config.getConnection(conn.id)).toBeUndefined();
});
it('refuses a connection that belongs to a different trip', async () => {
const conn = await storedPaperless();
const err = await thrown(() => controller.deleteConnection(String(otherTripId), String(conn.id), admin));
expect(err.getStatus()).toBe(404);
expect(config.getConnection(conn.id)).toBeTruthy();
expect(await config.getConnection(conn.id)).toBeTruthy();
expect(realtime.broadcast).not.toHaveBeenCalled();
});
@@ -498,14 +503,14 @@ describe('removing a connection', () => {
credentials: { login_name: 'alice', app_password: 'app-pw', base_path: '/TREK' },
}));
if ('error' in cloud) throw new Error(`fixture failed: ${cloud.error.code}`);
const bind = (connectionId: number, scopeKey: string) => {
const res = config.createLink(tripId, Number(owner.id), linkBody(connectionId, { scopeKey }));
const bind = async (connectionId: number, scopeKey: string) => {
const res = await config.createLink(tripId, Number(owner.id), linkBody(connectionId, { scopeKey }));
if ('error' in res) throw new Error(`fixture failed: ${res.error.code}`);
return res.data.id;
};
const first = bind(conn.id, 'tag:1');
const second = bind(conn.id, 'tag:2');
bind(cloud.data.id, 'folder:1');
const first = await bind(conn.id, 'tag:1');
const second = await bind(conn.id, 'tag:2');
await bind(cloud.data.id, 'folder:1');
await controller.deleteConnection(String(tripId), String(conn.id), owner);
@@ -593,7 +598,7 @@ describe('creating a binding', () => {
makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'trek.example' }),
)) as { id: number };
const row = config.getLink(link.id)!;
const row = (await config.getLink(link.id))!;
expect(row.webhook_subscription_id).toBe('sub-7');
expect(paperless.registerWebhook.mock.calls[0][2]).toBe(`https://trek.example/api/docsync/webhook/${row.webhook_token}`);
// The secret handed to the provider is the plaintext one, not the stored blob.
@@ -611,7 +616,7 @@ describe('creating a binding', () => {
makeReq({ 'x-forwarded-host': 'trek.example' }),
)) as { id: number; syncEnabled: boolean };
const row = config.getLink(link.id)!;
const row = (await config.getLink(link.id))!;
expect(row.webhook_subscription_id).toBeNull();
expect(row.sync_enabled).toBe(1);
});
@@ -675,7 +680,7 @@ describe('changing a binding', () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
const res = controller.updateLink(String(tripId), String(created.id), owner, { syncEnabled: false }, makeReq()) as {
const res = (await controller.updateLink(String(tripId), String(created.id), owner, { syncEnabled: false }, makeReq())) as {
syncEnabled: boolean;
direction: string;
remoteLabel: string;
@@ -691,7 +696,7 @@ describe('changing a binding', () => {
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
const err = await thrown(() => controller.updateLink(String(otherTripId), String(created.id), admin, { syncEnabled: false }, makeReq()));
expect(err.getStatus()).toBe(404);
expect(config.getLink(created.id)!.sync_enabled).toBe(1);
expect((await config.getLink(created.id))!.sync_enabled).toBe(1);
});
});
@@ -775,14 +780,14 @@ describe('the document list', () => {
it('carries the local file name, so the list reads as documents rather than as ids', async () => {
await seedItems();
const rows = controller.items(String(tripId)) as Array<{ trek_doc_uid: string; file_name: string | null }>;
const rows = (await controller.items(String(tripId))) as Array<{ trek_doc_uid: string; file_name: string | null }>;
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.trek_doc_uid === 'uid-1')!.file_name).toBe('boarding.pdf');
});
it('narrows to one state when asked, and never leaves the trip', async () => {
await seedItems();
const rows = controller.items(String(tripId), 'conflict') as Array<{ trek_doc_uid: string }>;
const rows = (await controller.items(String(tripId), 'conflict')) as Array<{ trek_doc_uid: string }>;
expect(rows.map((r) => r.trek_doc_uid)).toEqual(['uid-2']);
});
});
@@ -817,7 +822,7 @@ describe('removing a binding', () => {
expect(res).toEqual({ success: true, documentsKept: true });
expect(paperless.unregisterWebhook.mock.calls[0][1]).toBe('sub-7');
expect(config.getLink(created.id)).toBeUndefined();
expect(await config.getLink(created.id)).toBeUndefined();
expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(fileId)).toBeTruthy();
});
@@ -839,7 +844,7 @@ describe('removing a binding', () => {
realtime.broadcast.mockClear();
const err = await thrown(() => controller.deleteLink(String(otherTripId), String(created.id), admin));
expect(err.getStatus()).toBe(404);
expect(config.getLink(created.id)).toBeTruthy();
expect(await config.getLink(created.id)).toBeTruthy();
expect(realtime.broadcast).not.toHaveBeenCalled();
});
});
@@ -848,30 +853,30 @@ describe('the callback origin a provider is given', () => {
async function linkedTrip() {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
return config.getLink(created.id)!;
return (await config.getLink(created.id))!;
}
it('builds the callback URL from the forwarded proto and host', async () => {
const row = await linkedTrip();
const [link] = controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'docs.example' })) as Array<{ webhookUrl: string }>;
const [link] = (await controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https', 'x-forwarded-host': 'docs.example' }))) as Array<{ webhookUrl: string }>;
expect(link.webhookUrl).toBe(`https://docs.example/api/docsync/webhook/${row.webhook_token}`);
});
it('takes the first hop when a proxy chain appends its own protocol', async () => {
await linkedTrip();
const [link] = controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https, http', 'x-forwarded-host': 'docs.example' })) as Array<{ webhookUrl: string }>;
const [link] = (await controller.listLinks(String(tripId), makeReq({ 'x-forwarded-proto': 'https, http', 'x-forwarded-host': 'docs.example' }))) as Array<{ webhookUrl: string }>;
expect(link.webhookUrl).toMatch(/^https:\/\/docs\.example\//);
});
it('falls back to the Host header and the connection scheme when nothing is forwarded', async () => {
await linkedTrip();
const [link] = controller.listLinks(String(tripId), makeReq({ host: 'trek.lan:3001' }, 'http')) as Array<{ webhookUrl: string }>;
const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.lan:3001' }, 'http'))) as Array<{ webhookUrl: string }>;
expect(link.webhookUrl).toMatch(/^http:\/\/trek\.lan:3001\//);
});
it('offers no callback URL when a proxy strips the host', async () => {
await linkedTrip();
const [link] = controller.listLinks(String(tripId), makeReq()) as Array<{ webhookUrl: string | null }>;
const [link] = (await controller.listLinks(String(tripId), makeReq())) as Array<{ webhookUrl: string | null }>;
expect(link.webhookUrl).toBeNull();
});
@@ -879,15 +884,15 @@ describe('the callback origin a provider is given', () => {
// Synology takes no webhook. Showing the address with "paste this into
// your provider" next to it promised something there was nowhere to do.
const row = await linkedTrip();
config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'none' });
const [link] = controller.listLinks(String(tripId), makeReq({ host: 'trek.example' })) as Array<{ webhookUrl: string | null }>;
await config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'none' });
const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.example' }))) as Array<{ webhookUrl: string | null }>;
expect(link.webhookUrl).toBeNull();
});
it('keeps offering it for a store the probe found able to take one', async () => {
const row = await linkedTrip();
config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'webhook-manual' });
const [link] = controller.listLinks(String(tripId), makeReq({ host: 'trek.example' })) as Array<{ webhookUrl: string | null }>;
await config.recordProbe(row.connection_id, 'ok', null, { ...CAPS, push: 'webhook-manual' });
const [link] = (await controller.listLinks(String(tripId), makeReq({ host: 'trek.example' }))) as Array<{ webhookUrl: string | null }>;
expect(link.webhookUrl).toBe(`http://trek.example/api/docsync/webhook/${row.webhook_token}`);
});
});
@@ -106,48 +106,48 @@ function makeJob(over: Partial<Setup> = {}) {
beforeEach(() => vi.clearAllMocks());
describe('DocSyncJob bootstrap', () => {
it('schedules nothing, reads nothing and logs nothing while the registrar is off', () => {
it('schedules nothing, reads nothing and logs nothing while the registrar is off', async () => {
// The test gate. A job that registered past it would have every suite boot
// start polling whatever document store sits in the fixture database.
const { job, registrar, db } = makeJob({ registrarEnabled: false });
job.onApplicationBootstrap();
await job.onApplicationBootstrap();
expect(registrar.register).not.toHaveBeenCalled();
expect(db.get).not.toHaveBeenCalled();
expect(log.logInfo).not.toHaveBeenCalled();
});
it('registers one cron under a name of its own, on the minute', () => {
it('registers one cron under a name of its own, on the minute', async () => {
// Every minute, with the tick deciding whether it is due. Baking the
// interval into the expression at bootstrap meant a changed setting did
// nothing until a restart, which is the opposite of what this job's own
// comment promises, and nothing re-registers it (auto-backup has a start()
// its settings save calls; this has no such path).
const { job, registrar } = makeJob();
job.onApplicationBootstrap();
await job.onApplicationBootstrap();
expect(registrar.register).toHaveBeenCalledWith('docsync', '* * * * *', expect.any(Function));
expect(log.logInfo).toHaveBeenCalledWith('Document sync: polling every 300s');
});
it('reads the interval from its own app_settings key', () => {
it('reads the interval from its own app_settings key', async () => {
const { job, db } = makeJob({ interval: '600' });
job.onApplicationBootstrap();
await job.onApplicationBootstrap();
expect(db.get).toHaveBeenCalledWith('SELECT value FROM app_settings WHERE key = ?', SETTING_POLL_INTERVAL);
});
it('hands the registrar the tick itself, so a fired cron reaches the sync', async () => {
const { job, sync, takeTick } = makeJob({ links: [link(1)] });
job.onApplicationBootstrap();
await job.onApplicationBootstrap();
const tick = takeTick();
expect(tick).toBeTypeOf('function');
await tick?.();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('does not decide at bootstrap whether the addon is on', () => {
it('does not decide at bootstrap whether the addon is on', async () => {
// Asking here would freeze the answer for the life of the process, and the
// bug would read as "the documents toggle needs a restart".
const { job, addons } = makeJob();
job.onApplicationBootstrap();
await job.onApplicationBootstrap();
expect(addons.isAddonEnabled).not.toHaveBeenCalled();
});
});
@@ -421,9 +421,10 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => {
const addons = { isAddonEnabled: vi.fn(() => true) } as unknown as AddonsService;
const registry = new DocumentProviderRegistry([paperless, nextcloud] as unknown as DocumentProvider[]);
const config = new DocSyncConfigService(dbs, registry);
// Built in beforeAll: DocSyncService now takes a UnitOfWork, and
// createTestUnitOfWork is async — module-scope construction cannot await it.
// Built in beforeAll: DocSyncConfigService and DocSyncService now take a
// UnitOfWork, and createTestUnitOfWork is async — module-scope construction
// cannot await it.
let config: DocSyncConfigService;
let service: DocSyncService;
const registrar = { isEnabled: () => false } as unknown as CronRegistrarService;
/** A job of its own per pass, so the interval never decides whether a tick runs. */
@@ -434,6 +435,7 @@ describe('DocSyncJob and a provider switched off in the admin panel', () => {
const linkRow = (id: number) => testDb.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id);
beforeAll(async () => {
config = new DocSyncConfigService(dbs, registry, await createTestUnitOfWork(testDb));
service = new DocSyncService(
dbs, config, registry,
{} as StorageService, {} as FilesService, new AllowedFileTypesService(dbs),
File diff suppressed because it is too large Load Diff
@@ -470,7 +470,7 @@ describe('SynologyDriveDocumentProvider: device tokens', () => {
function saved(store: Record<string, string>, overrides: Partial<DocumentConnectionRef> = {}): DocumentConnectionRef {
return connection({
secrets: { ...store },
saveSecret: (key, value) => {
saveSecret: async (key, value) => {
writes.push([key, value]);
if (value === null) delete store[key];
else store[key] = value;
+2 -2
View File
@@ -143,7 +143,7 @@ beforeAll(async () => {
daysSvc,
new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)),
budgetSvc,
new VacayService(dbs(), new RealtimeService(), notificationsStub()),
new VacayService(dbs(), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
new RealtimeService(),
undefined as never, // unsplash — not exercised here
coversFx.storage,
@@ -1210,7 +1210,7 @@ describe('quirk fixes', () => {
daysSvc,
new PermissionsService(dbs(), await createTestUnitOfWork(dbs().connection)),
budgetSvc,
new VacayService(dbs(), new RealtimeService(), notificationsStub()),
new VacayService(dbs(), new RealtimeService(), notificationsStub(), await createTestUnitOfWork(dbs().connection)),
new RealtimeService(),
undefined as never,
coversFx.storage,
+41 -41
View File
@@ -25,9 +25,9 @@ async function thrown(fn: () => unknown): Promise<{ status: number; body: unknow
const planBase = { getActivePlanId: vi.fn().mockReturnValue(10), getActivePlan: vi.fn().mockReturnValue({ id: 10 }) };
describe('VacayController (parity with the legacy /api/addons/vacay route)', () => {
it('GET /plan delegates getPlanData', () => {
it('GET /plan delegates getPlanData', async () => {
const getPlanData = vi.fn().mockReturnValue({ plan: { id: 10 } });
expect(makeController({ getPlanData }).getPlan(user)).toEqual({ plan: { id: 10 } });
expect(await makeController({ getPlanData }).getPlan(user)).toEqual({ plan: { id: 10 } });
});
it('PUT /plan forwards the socket id', async () => {
@@ -39,9 +39,9 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
describe('holiday calendars', () => {
// A missing region now 400s in the global ZodValidationPipe (the schema
// requires it), so the bespoke 'region required' guard is gone.
it('creates a calendar', () => {
it('creates a calendar', async () => {
const addHolidayCalendar = vi.fn().mockReturnValue({ id: 1, region: 'DE-BY' });
const res = makeController({ ...planBase, addHolidayCalendar }).addHolidayCalendar(user, { region: 'DE-BY', label: 'Bayern' }, 'sock');
const res = await makeController({ ...planBase, addHolidayCalendar }).addHolidayCalendar(user, { region: 'DE-BY', label: 'Bayern' }, 'sock');
expect(res).toEqual({ calendar: { id: 1, region: 'DE-BY' } });
expect(addHolidayCalendar).toHaveBeenCalledWith(10, 'DE-BY', 'Bayern', undefined, undefined, 'sock', undefined);
});
@@ -66,10 +66,10 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
expect(r).toEqual({ status: 403, body: { error: 'User not in plan' } }));
});
it('sets the colour for an in-plan user', () => {
it('sets the colour for an in-plan user', async () => {
const getPlanUsers = vi.fn().mockReturnValue([{ id: 1 }]);
const setUserColor = vi.fn();
expect(makeController({ ...planBase, getPlanUsers, setUserColor }).setColor(user, { color: '#fff' }, 'sock')).toEqual({ success: true });
expect(await makeController({ ...planBase, getPlanUsers, setUserColor }).setColor(user, { color: '#fff' }, 'sock')).toEqual({ success: true });
expect(setUserColor).toHaveBeenCalledWith(1, 10, '#fff', 'sock');
});
});
@@ -88,9 +88,9 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
expect(r).toEqual({ status: 409, body: { error: 'Already in a plan' } }));
});
it('sends an invite', () => {
it('sends an invite', async () => {
const sendInvite = vi.fn().mockReturnValue({});
expect(makeController({ ...planBase, sendInvite }).invite(user, { user_id: 2 })).toEqual({ success: true });
expect(await makeController({ ...planBase, sendInvite }).invite(user, { user_id: 2 })).toEqual({ success: true });
expect(sendInvite).toHaveBeenCalledWith(10, 1, 'u', 'u@example.test', 2);
});
@@ -100,11 +100,11 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
expect(r).toEqual({ status: 404, body: { error: 'Invite not found' } }));
});
it('decline / cancel / dissolve return success', () => {
it('decline / cancel / dissolve return success', async () => {
const declineInvite = vi.fn(); const cancelInvite = vi.fn(); const dissolvePlan = vi.fn();
expect(makeController({ declineInvite }).declineInvite(user, { plan_id: 5 })).toEqual({ success: true });
expect(makeController({ ...planBase, cancelInvite }).cancelInvite(user, { user_id: 2 })).toEqual({ success: true });
expect(makeController({ dissolvePlan }).dissolve(user)).toEqual({ success: true });
expect(await makeController({ declineInvite }).declineInvite(user, { plan_id: 5 })).toEqual({ success: true });
expect(await makeController({ ...planBase, cancelInvite }).cancelInvite(user, { user_id: 2 })).toEqual({ success: true });
expect(await makeController({ dissolvePlan }).dissolve(user)).toEqual({ success: true });
});
});
@@ -116,10 +116,10 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
expect(r).toEqual({ status: 400, body: { error: 'Year required' } }));
});
it('adds and deletes years', () => {
it('adds and deletes years', async () => {
const addYear = vi.fn().mockReturnValue([2026]); const deleteYear = vi.fn().mockReturnValue([]);
expect(makeController({ ...planBase, addYear }).addYear(user, { year: 2026 }, 'sock')).toEqual({ years: [2026] });
expect(makeController({ ...planBase, deleteYear }).deleteYear(user, '2026', 'sock')).toEqual({ years: [] });
expect(await makeController({ ...planBase, addYear }).addYear(user, { year: 2026 }, 'sock')).toEqual({ years: [2026] });
expect(await makeController({ ...planBase, deleteYear }).deleteYear(user, '2026', 'sock')).toEqual({ years: [] });
});
});
@@ -132,15 +132,15 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
expect(r).toEqual({ status: 403, body: { error: 'User not in plan' } }));
});
it('toggles for the caller', () => {
it('toggles for the caller', async () => {
const toggleEntry = vi.fn().mockReturnValue({ action: 'added' });
expect(makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01' }, 'sock')).toEqual({ action: 'added' });
expect(await makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01' }, 'sock')).toEqual({ action: 'added' });
expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, undefined, 'sock');
});
it('forwards the half-day fraction (#552)', () => {
it('forwards the half-day fraction (#552)', async () => {
const toggleEntry = vi.fn().mockReturnValue({ action: 'added', fraction: 0.5 });
makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', fraction: 0.5 }, 'sock');
await makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', fraction: 0.5 }, 'sock');
expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', 0.5, undefined, 'sock');
});
@@ -150,30 +150,30 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
expect(r).toEqual({ status: 400, body: { error: 'Weekend days are blocked on this plan' } }));
});
it('forwards the comp/flex leave type (#1074)', () => {
it('forwards the comp/flex leave type (#1074)', async () => {
const toggleEntry = vi.fn().mockReturnValue({ action: 'added', kind: 'comp' });
makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', kind: 'comp' }, 'sock');
await makeController({ ...planBase, toggleEntry }).toggleEntry(user, { date: '2026-07-01', kind: 'comp' }, 'sock');
expect(toggleEntry).toHaveBeenCalledWith(1, 10, '2026-07-01', undefined, 'comp', 'sock');
});
it('GET reads entries over the caller window (#737)', () => {
it('GET reads entries over the caller window (#737)', async () => {
const getEntries = vi.fn().mockReturnValue({ entries: [], companyHolidays: [] });
makeController({ ...planBase, getEntries }).entries(user, '2026');
await makeController({ ...planBase, getEntries }).entries(user, '2026');
expect(getEntries).toHaveBeenCalledWith(10, '2026', 1);
});
});
describe('year settings (#737)', () => {
it('GET wraps the caller settings', () => {
it('GET wraps the caller settings', async () => {
const getYearSettings = vi.fn().mockReturnValue({ year_type: 'calendar' });
expect(makeController({ getYearSettings }).yearSettings(user)).toEqual({ settings: { year_type: 'calendar' } });
expect(await makeController({ getYearSettings }).yearSettings(user)).toEqual({ settings: { year_type: 'calendar' } });
expect(getYearSettings).toHaveBeenCalledWith(1);
});
it('PUT saves for the caller and returns the stored settings', () => {
it('PUT saves for the caller and returns the stored settings', async () => {
const updateYearSettings = vi.fn().mockReturnValue({ year_type: 'fiscal', year_start_month: 7, year_start_day: 1 });
const body = { year_type: 'fiscal' as const, year_start_month: 7, year_start_day: 1 };
expect(makeController({ updateYearSettings }).updateYearSettings(user, body)).toEqual({
expect(await makeController({ updateYearSettings }).updateYearSettings(user, body)).toEqual({
settings: { year_type: 'fiscal', year_start_month: 7, year_start_day: 1 },
});
expect(updateYearSettings).toHaveBeenCalledWith(1, body);
@@ -181,9 +181,9 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
});
describe('stats', () => {
it('GET wraps stats', () => {
it('GET wraps stats', async () => {
const getStats = vi.fn().mockReturnValue({ used: 5 });
expect(makeController({ ...planBase, getStats }).stats(user, '2026')).toEqual({ stats: { used: 5 } });
expect(await makeController({ ...planBase, getStats }).stats(user, '2026')).toEqual({ stats: { used: 5 } });
});
it('403 on updateStats for a user not in the plan', () => {
@@ -213,9 +213,9 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
});
describe('read-only shares', () => {
it('GET /shares delegates listShares', () => {
it('GET /shares delegates listShares', async () => {
const listShares = vi.fn().mockReturnValue({ outgoing: [{ id: 1 }], incoming: [] });
expect(makeController({ listShares }).shares(user)).toEqual({ outgoing: [{ id: 1 }], incoming: [] });
expect(await makeController({ listShares }).shares(user)).toEqual({ outgoing: [{ id: 1 }], incoming: [] });
expect(listShares).toHaveBeenCalledWith(1);
});
@@ -230,27 +230,27 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
expect(r).toEqual({ status: 400, body: { error: 'Already shared' } }));
});
it('shares a calendar (user_id coerced to a number, socket id forwarded)', () => {
it('shares a calendar (user_id coerced to a number, socket id forwarded)', async () => {
const shareCalendar = vi.fn().mockReturnValue({});
expect(makeController({ shareCalendar }).share(user, { user_id: '2' }, 'sock-1')).toEqual({ success: true });
expect(await makeController({ shareCalendar }).share(user, { user_id: '2' }, 'sock-1')).toEqual({ success: true });
expect(shareCalendar).toHaveBeenCalledWith(1, 'u@example.test', 2, 'sock-1');
});
it('GET /shares/available-users wraps the user list', () => {
it('GET /shares/available-users wraps the user list', async () => {
const getShareAvailableUsers = vi.fn().mockReturnValue([{ id: 3 }]);
expect(makeController({ getShareAvailableUsers }).shareAvailableUsers(user)).toEqual({ users: [{ id: 3 }] });
expect(await makeController({ getShareAvailableUsers }).shareAvailableUsers(user)).toEqual({ users: [{ id: 3 }] });
expect(getShareAvailableUsers).toHaveBeenCalledWith(1);
});
it('GET /shares/calendars/:year wraps the calendars', () => {
it('GET /shares/calendars/:year wraps the calendars', async () => {
const getSharedCalendars = vi.fn().mockReturnValue([{ share_id: 4 }]);
expect(makeController({ getSharedCalendars }).sharedCalendars(user, '2026')).toEqual({ calendars: [{ share_id: 4 }] });
expect(await makeController({ getSharedCalendars }).sharedCalendars(user, '2026')).toEqual({ calendars: [{ share_id: 4 }] });
expect(getSharedCalendars).toHaveBeenCalledWith(1, '2026');
});
it('PUT /shares/:id forwards the socket id and the hidden flag', () => {
it('PUT /shares/:id forwards the socket id and the hidden flag', async () => {
const setShareHidden = vi.fn().mockReturnValue(true);
expect(makeController({ setShareHidden }).updateShare(user, '7', { hidden: true }, 'sock-1')).toEqual({ success: true });
expect(await makeController({ setShareHidden }).updateShare(user, '7', { hidden: true }, 'sock-1')).toEqual({ success: true });
expect(setShareHidden).toHaveBeenCalledWith(7, 1, true, 'sock-1');
});
@@ -260,9 +260,9 @@ describe('VacayController (parity with the legacy /api/addons/vacay route)', ()
expect(r).toEqual({ status: 404, body: { error: 'Share not found' } }));
});
it('DELETE /shares/:id forwards the socket id', () => {
it('DELETE /shares/:id forwards the socket id', async () => {
const removeShare = vi.fn().mockReturnValue(true);
expect(makeController({ removeShare }).deleteShare(user, '7', 'sock-1')).toEqual({ success: true });
expect(await makeController({ removeShare }).deleteShare(user, '7', 'sock-1')).toEqual({ success: true });
expect(removeShare).toHaveBeenCalledWith(7, 1, 'sock-1');
});
File diff suppressed because it is too large Load Diff