mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
refactor(vacay, admin, doc-sync): async DB-touching methods; transactions through UnitOfWork
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' };
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }] });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user