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

This commit is contained in:
jubnl
2026-09-22 03:29:33 +02:00
parent 567593a259
commit cb177ce655
28 changed files with 1400 additions and 1356 deletions
+5 -5
View File
@@ -75,19 +75,19 @@ export class AdminController {
// ── Users ──
@Get('users')
listUsers() { return { users: this.admin.listUsers() }; }
async listUsers() { return { users: await this.admin.listUsers() }; }
@Post('users')
@HttpCode(201)
async createUser(@CurrentUser() user: User, @Body() body: AdminUserCreateDto, @Req() req: Request) {
const result = ok(this.admin.createUser(body as Parameters<AdminService['createUser']>[0]));
const result = ok(await this.admin.createUser(body as Parameters<AdminService['createUser']>[0]));
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_create', resource: String(result.insertedId), ip: getClientIp(req), details: result.auditDetails });
return { user: result.user };
}
@Put('users/:id')
async updateUser(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminUserUpdateDto, @Req() req: Request) {
const result = ok(this.admin.updateUser(id, body));
const result = ok(await this.admin.updateUser(id, body));
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_update', resource: String(id), ip: getClientIp(req), details: { targetUser: result.previousEmail, fields: result.changed } });
logInfo(`Admin ${user.email} edited user ${result.previousEmail} (fields: ${result.changed.join(', ')})`);
return { user: result.user };
@@ -110,7 +110,7 @@ export class AdminController {
@Delete('users/:id/mfa')
async resetUserMfa(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
const result = ok(this.admin.resetUserMfa(id, user.id));
const result = ok(await this.admin.resetUserMfa(id, user.id));
await this.audit.writeAudit({ userId: user.id, action: 'admin.user_mfa_reset', resource: String(id), ip: getClientIp(req), details: { targetUser: result.email } });
return { success: true };
}
@@ -280,7 +280,7 @@ export class AdminController {
// ── Addons ──
@Get('addons')
listAddons() { return { addons: this.admin.listAddons() }; }
async listAddons() { return { addons: await this.admin.listAddons() }; }
@Put('addons/:id')
async updateAddon(@CurrentUser() user: User, @Param('id') id: string, @Body() body: AdminAddonUpdateDto, @Req() req: Request) {
+11 -9
View File
@@ -22,6 +22,7 @@ import { getPhotoProviderConfig } from '../memories/memories.helpers';
import { validatePassword } from '../common/passwordPolicy';
import { UserCleanupService } from '../auth/user-cleanup.service';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { AddonsService } from '../addons/addons.service';
import { RealtimeService } from '../realtime/realtime.service';
import { PasskeyService } from '../auth/passkey.service';
@@ -76,11 +77,12 @@ export class AdminService {
private readonly notifications: NotificationsService,
private readonly userCleanup: UserCleanupService,
private readonly realtime: RealtimeService,
private readonly uow: UnitOfWork,
) {}
// ── User CRUD ──────────────────────────────────────────────────────────────
listUsers() {
async listUsers() {
// Guests (#1362) are accountless trip participants, not real users — keep them out
// of admin user management entirely.
const users = this.db.all<
@@ -109,7 +111,7 @@ export class AdminService {
}));
}
createUser(data: { username: string; email: string; password: string; role?: string }) {
async createUser(data: { username: string; email: string; password: string; role?: string }) {
const username = data.username?.trim();
const email = data.email?.trim();
const password = data.password?.trim();
@@ -151,7 +153,7 @@ export class AdminService {
};
}
updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) {
async updateUser(id: string, data: { username?: string; email?: string; role?: string; password?: string }) {
const username = typeof data.username === 'string' ? data.username.trim() : data.username;
const email = typeof data.email === 'string' ? data.email.trim() : data.email;
const { role, password } = data;
@@ -200,7 +202,7 @@ export class AdminService {
// (changePassword, resetPassword) have always done this; this one had not.
const newPv = password ? ((user as User & { password_version?: number }).password_version ?? 0) + 1 : null;
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run(
`
UPDATE users SET
@@ -276,7 +278,7 @@ export class AdminService {
* making that reachable from here would turn a stolen admin session into a
* way to strip the second factor off the very account it came from.
*/
resetUserMfa(id: string, actingUserId: number): { error?: string; status?: number; success?: boolean; email?: string } {
async resetUserMfa(id: string, actingUserId: number): Promise<{ error?: string; status?: number; success?: boolean; email?: string }> {
const targetId = Number(id);
if (targetId === actingUserId) {
return { error: 'Use Settings to change your own two-factor setup', status: 400 };
@@ -299,7 +301,7 @@ export class AdminService {
// ── Stats ──────────────────────────────────────────────────────────────────
getStats() {
async getStats() {
const totalUsers = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM users WHERE COALESCE(is_guest, 0) = 0')!.count;
const totalTrips = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM trips')!.count;
const totalPlaces = this.db.get<{ count: number }>('SELECT COUNT(*) as count FROM places')!.count;
@@ -327,7 +329,7 @@ export class AdminService {
// ── Audit Log ──────────────────────────────────────────────────────────────
getAuditLog(query: { limit?: string; offset?: string }) {
async getAuditLog(query: { limit?: string; offset?: string }) {
const limitRaw = Number.parseInt(String(query.limit || '100'), 10);
const offsetRaw = Number.parseInt(String(query.offset || '0'), 10);
const limit = Math.min(Math.max(Number.isFinite(limitRaw) ? limitRaw : 100, 1), 500);
@@ -538,7 +540,7 @@ export class AdminService {
// ── Addons ─────────────────────────────────────────────────────────────────
listAddons() {
async listAddons() {
const addons = this.db
.all<Addon>('SELECT * FROM addons ORDER BY sort_order, id')
// Hidden rather than shown-and-refused, because a toggle that answers 403
@@ -693,7 +695,7 @@ export class AdminService {
return { error: 'Enable the Documents addon first', status: 409 };
}
this.db.transaction(() => {
await this.uow.transactional(async () => {
if (addon) {
if (data.enabled !== undefined) {
this.db.run('UPDATE addons SET enabled = ? WHERE id = ?', data.enabled ? 1 : 0, id);
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import crypto from 'crypto';
import { DOCSYNC_SECRET_MASK, type DocsyncConnectionInput, type DocsyncLinkInput } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { checkSsrf } from '../../utils/ssrfGuard';
import { DocumentProviderRegistry } from './document-provider.registry';
import type { DocumentConnectionRef, DocumentScopeRef, DocResult } from './document-provider';
@@ -86,19 +87,20 @@ export class DocSyncConfigService {
constructor(
private readonly db: DatabaseService,
private readonly registry: DocumentProviderRegistry,
private readonly uow: UnitOfWork,
) {}
// ── Provider metadata ──────────────────────────────────────────────────────
/** Only providers the instance admin switched on may be configured. */
enabledProviderIds(): string[] {
async enabledProviderIds(): Promise<string[]> {
return this.db.connection
.prepare('SELECT id FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
.all()
.map((r) => (r as { id: string }).id);
}
providerFields(providerId: string): ProviderFieldRow[] {
async providerFields(providerId: string): Promise<ProviderFieldRow[]> {
// Every column the form needs, not just the three the secret bookkeeping
// uses: the client renders the field from this row, and a missing `label`
// turns into a literal "docsync.undefined" on screen.
@@ -110,8 +112,8 @@ export class DocSyncConfigService {
.all(providerId) as ProviderFieldRow[];
}
private secretKeys(providerId: string): string[] {
return this.providerFields(providerId).filter((f) => f.secret === 1).map((f) => f.field_key);
private async secretKeys(providerId: string): Promise<string[]> {
return (await this.providerFields(providerId)).filter((f) => f.secret === 1).map((f) => f.field_key);
}
/**
@@ -119,7 +121,7 @@ export class DocSyncConfigService {
* admin switching its provider off, and the client only gets names for the
* providers that are still on, so without this it would show the raw id.
*/
private providerName(providerId: string): string {
private async providerName(providerId: string): Promise<string> {
const row = this.db.connection
.prepare('SELECT name FROM document_providers WHERE id = ?')
.get(providerId) as { name: string } | undefined;
@@ -128,11 +130,11 @@ export class DocSyncConfigService {
// ── Connections ────────────────────────────────────────────────────────────
getConnection(id: number): ConnectionRow | undefined {
async getConnection(id: number): Promise<ConnectionRow | undefined> {
return this.db.connection.prepare('SELECT * FROM document_connections WHERE id = ?').get(id) as ConnectionRow | undefined;
}
listConnections(tripId: number): ConnectionRow[] {
async listConnections(tripId: number): Promise<ConnectionRow[]> {
return this.db.connection
.prepare('SELECT * FROM document_connections WHERE trip_id = ? ORDER BY id')
.all(tripId) as ConnectionRow[];
@@ -183,9 +185,9 @@ export class DocSyncConfigService {
* caller's ref: a sync run holds its ref for minutes, and writing that
* snapshot back would undo a password the owner changed in the meantime.
*/
saveEarnedSecret(connectionId: number, key: string, value: string | null): void {
this.db.transaction(() => {
const row = this.getConnection(connectionId);
async saveEarnedSecret(connectionId: number, key: string, value: string | null): Promise<void> {
await this.uow.transactional(async () => {
const row = await this.getConnection(connectionId);
if (!row) return;
const secrets = decryptSecrets(row.secrets);
if (value === null) delete secrets[key];
@@ -197,7 +199,7 @@ export class DocSyncConfigService {
}
/** What a client may see: no secret values, only whether each one is set. */
publicConnection(row: ConnectionRow): Record<string, unknown> {
async publicConnection(row: ConnectionRow): Promise<Record<string, unknown>> {
const ref = this.toRef(row);
return {
id: row.id,
@@ -206,7 +208,7 @@ export class DocSyncConfigService {
ownerUserId: row.owner_user_id,
baseUrl: row.base_url,
settings: ref.settings,
secrets: maskSecrets(ref.secrets, this.secretKeys(row.provider_id)),
secrets: maskSecrets(ref.secrets, await this.secretKeys(row.provider_id)),
allowInsecureTls: row.allow_insecure_tls === 1,
capabilities: row.capabilities ? safeParse(row.capabilities) : null,
lastProbeAt: row.last_probe_at,
@@ -266,7 +268,7 @@ export class DocSyncConfigService {
const urlCheck = await this.validateBaseUrl(input.baseUrl);
if (docFailed(urlCheck)) return { success: false, error: urlCheck.error };
const fields = this.providerFields(input.providerId);
const fields = await this.providerFields(input.providerId);
const secretKeys = fields.filter((f) => f.secret === 1).map((f) => f.field_key);
const plainKeys = fields.filter((f) => f.secret !== 1).map((f) => f.field_key);
@@ -316,7 +318,7 @@ export class DocSyncConfigService {
const settingsJson = JSON.stringify(settings);
const insecure = input.allowInsecureTls ? 1 : 0;
const row = this.db.transaction(() => {
const row = await this.uow.transactional(async () => {
if (existing) {
this.db.connection
.prepare(
@@ -326,7 +328,7 @@ export class DocSyncConfigService {
WHERE id = ?`,
)
.run(urlCheck.data.url, encrypted, settingsJson, insecure, ownerId, existing.id);
return this.getConnection(existing.id) as ConnectionRow;
return (await this.getConnection(existing.id)) as ConnectionRow;
}
const info = this.db.connection
.prepare(
@@ -334,13 +336,13 @@ export class DocSyncConfigService {
VALUES (?, ?, ?, ?, ?, ?, ?)`,
)
.run(tripId, input.providerId, userId, urlCheck.data.url, encrypted, settingsJson, insecure);
return this.getConnection(Number(info.lastInsertRowid)) as ConnectionRow;
return (await this.getConnection(Number(info.lastInsertRowid))) as ConnectionRow;
});
return { success: true, data: row };
}
recordProbe(connectionId: number, state: 'ok' | 'failed', error: string | null, capabilities: unknown): void {
async recordProbe(connectionId: number, state: 'ok' | 'failed', error: string | null, capabilities: unknown): Promise<void> {
this.db.connection
.prepare(
`UPDATE document_connections
@@ -356,23 +358,23 @@ export class DocSyncConfigService {
* more: the same promise Dawarich's disconnect makes, and the only version
* of this action that is safe to offer without a confirmation dialog.
*/
deleteConnection(id: number): void {
async deleteConnection(id: number): Promise<void> {
this.db.connection.prepare('DELETE FROM document_connections WHERE id = ?').run(id);
}
// ── Trip bindings ──────────────────────────────────────────────────────────
listLinks(tripId: number): LinkRow[] {
async listLinks(tripId: number): Promise<LinkRow[]> {
return this.db.connection
.prepare('SELECT * FROM trip_document_links WHERE trip_id = ? ORDER BY id')
.all(tripId) as LinkRow[];
}
getLink(id: number): LinkRow | undefined {
async getLink(id: number): Promise<LinkRow | undefined> {
return this.db.connection.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id) as LinkRow | undefined;
}
getLinkByToken(token: string): LinkRow | undefined {
async getLinkByToken(token: string): Promise<LinkRow | undefined> {
return this.db.connection
.prepare('SELECT * FROM trip_document_links WHERE webhook_token = ?')
.get(token) as LinkRow | undefined;
@@ -397,8 +399,8 @@ export class DocSyncConfigService {
* still goes through the connection's own credential, so it cannot open
* anything that credential could not open anyway.
*/
createLink(tripId: number, userId: number, input: DocsyncLinkInput): DocResult<LinkRow> {
const conn = this.getConnection(input.connectionId);
async createLink(tripId: number, userId: number, input: DocsyncLinkInput): Promise<DocResult<LinkRow>> {
const conn = await this.getConnection(input.connectionId);
if (!conn || conn.trip_id !== tripId) {
return { success: false, error: { code: 'not_found', detail: 'connection not found for this trip' } };
}
@@ -436,18 +438,18 @@ export class DocSyncConfigService {
String(encryptSecrets({ webhook: secret })),
userId,
);
return { success: true, data: this.getLink(Number(info.lastInsertRowid)) as LinkRow };
return { success: true, data: (await this.getLink(Number(info.lastInsertRowid))) as LinkRow };
}
updateLink(id: number, patch: Partial<DocsyncLinkInput>): LinkRow | undefined {
async updateLink(id: number, patch: Partial<DocsyncLinkInput>): Promise<LinkRow | undefined> {
const sets: string[] = [];
const values: unknown[] = [];
// Sync automatically is the way back for an orphaned binding, but only once
// the person whose credential it runs under is on the trip again. Until
// then the switch stays off rather than handing that credential back.
const current = patch.syncEnabled === true ? this.getLink(id) : undefined;
const current = patch.syncEnabled === true ? await this.getLink(id) : undefined;
if (current?.last_sync_state === 'orphaned') {
if (this.ownerLeft(current.connection_id)) patch = { ...patch, syncEnabled: undefined };
if (await this.ownerLeft(current.connection_id)) patch = { ...patch, syncEnabled: undefined };
else sets.push("last_sync_state = 'never'");
}
if (patch.direction !== undefined) { sets.push('direction = ?'); values.push(patch.direction); }
@@ -455,10 +457,10 @@ export class DocSyncConfigService {
if (patch.conflictPolicy !== undefined) { sets.push('conflict_policy = ?'); values.push(patch.conflictPolicy); }
if (patch.syncEnabled !== undefined) { sets.push('sync_enabled = ?'); values.push(patch.syncEnabled ? 1 : 0); }
if (patch.remoteLabel !== undefined) { sets.push('remote_label = ?'); values.push(patch.remoteLabel); }
if (sets.length === 0) return this.getLink(id);
if (sets.length === 0) return await this.getLink(id);
sets.push('updated_at = CURRENT_TIMESTAMP');
this.db.connection.prepare(`UPDATE trip_document_links SET ${sets.join(', ')} WHERE id = ?`).run(...values, id);
return this.getLink(id);
return await this.getLink(id);
}
/**
@@ -466,8 +468,8 @@ export class DocSyncConfigService {
* document on either side. A user who wants that does it deliberately, in
* the system that holds the file.
*/
deleteLink(id: number): void {
this.db.transaction(() => {
async deleteLink(id: number): Promise<void> {
await this.uow.transactional(async () => {
this.db.connection.prepare('DELETE FROM document_sync_items WHERE link_id = ?').run(id);
this.db.connection.prepare('DELETE FROM trip_document_links WHERE id = ?').run(id);
});
@@ -478,13 +480,13 @@ export class DocSyncConfigService {
}
/** The masked view a client gets, with the webhook URL it may need to paste. */
publicLink(link: LinkRow, webhookBaseUrl: string | null): Record<string, unknown> {
async publicLink(link: LinkRow, webhookBaseUrl: string | null): Promise<Record<string, unknown>> {
return {
id: link.id,
tripId: link.trip_id,
connectionId: link.connection_id,
providerId: link.provider_id,
providerName: this.providerName(link.provider_id),
providerName: await this.providerName(link.provider_id),
scopeKey: link.remote_scope_key,
remoteRootId: link.remote_root_id,
remoteRootPath: link.remote_root_path,
@@ -501,7 +503,7 @@ export class DocSyncConfigService {
// subscribe on its own (Papra, and Nextcloud without admin rights). Not
// for a provider that takes no webhook at all: an address with nowhere
// to paste it only promises what the timer delivers anyway.
webhookUrl: webhookBaseUrl && link.webhook_token && this.takesWebhook(link)
webhookUrl: webhookBaseUrl && link.webhook_token && (await this.takesWebhook(link))
? `${webhookBaseUrl}/api/docsync/webhook/${link.webhook_token}`
: null,
webhookSecret: link.webhook_secret ? DOCSYNC_SECRET_MASK : null,
@@ -513,8 +515,8 @@ export class DocSyncConfigService {
* recorded it. Unknown counts as yes: a connection that was never probed
* still gets the address, and a stale answer costs nothing but a line.
*/
private takesWebhook(link: LinkRow): boolean {
const recorded = this.getConnection(link.connection_id)?.capabilities;
private async takesWebhook(link: LinkRow): Promise<boolean> {
const recorded = (await this.getConnection(link.connection_id))?.capabilities;
const caps = recorded ? safeParse(recorded) : null;
return !(caps && typeof caps === 'object' && (caps as { push?: unknown }).push === 'none');
}
@@ -525,11 +527,11 @@ export class DocSyncConfigService {
* marked; every path that starts a run asks here rather than reading the
* mark alone.
*/
isOrphaned(link: LinkRow): boolean {
return link.last_sync_state === 'orphaned' || this.ownerLeft(link.connection_id);
async isOrphaned(link: LinkRow): Promise<boolean> {
return link.last_sync_state === 'orphaned' || (await this.ownerLeft(link.connection_id));
}
private ownerLeft(connectionId: number): boolean {
private async ownerLeft(connectionId: number): Promise<boolean> {
return !!this.db.connection
.prepare(
`SELECT 1 FROM document_connections c
@@ -550,7 +552,7 @@ export class DocSyncConfigService {
* and a binding that keeps using an ex-member's token is the kind of thing
* nobody notices until it is a complaint.
*/
markOrphanedLinks(): number {
async markOrphanedLinks(): Promise<number> {
const info = this.db.connection
.prepare(
`UPDATE trip_document_links
@@ -74,7 +74,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
@Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.')
@HttpCode(200)
async nudge(@Param('token') token: string, @Req() req: Request) {
const link = this.config.getLinkByToken(token);
const link = await this.config.getLinkByToken(token);
// Always 200, even for an unknown token: a 404 here would let anyone probe
// which tokens exist, and a provider that gets an error will retry anyway.
if (!link || link.sync_enabled !== 1) return { received: true };
@@ -115,7 +115,7 @@ export class DocSyncWebhookController implements OnModuleDestroy {
const timer = setTimeout(() => {
void (async () => {
this.pending.delete(linkId);
const fresh = reload();
const fresh = await reload();
if (!fresh || fresh.sync_enabled !== 1) return;
if (!(await this.syncIsOn(fresh))) return;
void this.sync.syncLink(fresh).then((res) => {
+51 -47
View File
@@ -98,7 +98,7 @@ export class DocSyncController {
* The 403 still says "owner": an admin never sees it, and to everybody who
* does, the owner is the person to ask.
*/
private assertCanManage(tripId: string, user: User): void {
private async assertCanManage(tripId: string, user: User): Promise<void> {
if (user.role === 'admin') return;
const trip = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
if (!trip) throw new HttpException('Trip not found', 404);
@@ -111,17 +111,19 @@ export class DocSyncController {
/** Which providers this instance has switched on, with their form fields. */
@Get('providers')
providers() {
async providers() {
const rows = this.db.connection
.prepare('SELECT id, name, description, icon FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
.all() as Array<{ id: string; name: string; description: string | null; icon: string }>;
return rows.map((p) => ({
...p,
// A provider row with no registered adapter would render a form that
// cannot work, so it is reported rather than hidden.
available: !!this.registry.get(p.id),
fields: this.config.providerFields(p.id).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })),
}));
return await Promise.all(
rows.map(async (p) => ({
...p,
// A provider row with no registered adapter would render a form that
// cannot work, so it is reported rather than hidden.
available: !!this.registry.get(p.id),
fields: (await this.config.providerFields(p.id)).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })),
})),
);
}
@Get('status')
@@ -132,8 +134,9 @@ export class DocSyncController {
// ── Connections ────────────────────────────────────────────────────────────
@Get('connections')
listConnections(@Param('tripId') tripId: string) {
return this.config.listConnections(Number(tripId)).map((c) => this.config.publicConnection(c));
async listConnections(@Param('tripId') tripId: string) {
const rows = await this.config.listConnections(Number(tripId));
return await Promise.all(rows.map((c) => this.config.publicConnection(c)));
}
@Put('connections')
@@ -143,8 +146,8 @@ export class DocSyncController {
@CurrentUser() user: User,
@Body() body: DocsyncConnectionDto,
) {
this.assertCanManage(tripId, user);
if (!this.config.enabledProviderIds().includes(body.providerId)) {
await this.assertCanManage(tripId, user);
if (!(await this.config.enabledProviderIds()).includes(body.providerId)) {
throw new HttpException(`Provider: "${body.providerId}" is not enabled, contact server administrator`, 400);
}
const res = await this.config.upsertConnection(Number(tripId), Number(user.id), body);
@@ -164,7 +167,7 @@ export class DocSyncController {
@CurrentUser() user: User,
@Body() body: DocsyncConnectionTestDto,
) {
this.assertCanManage(tripId, user);
await this.assertCanManage(tripId, user);
const provider = this.registry.get(body.providerId);
if (!provider) return { connected: false, error: 'unknown_provider' };
@@ -173,9 +176,9 @@ export class DocSyncController {
// Probing with the values on screen, merged over whatever is stored, so a
// user testing an unchanged connection does not have to retype the secret.
const existing = this.config
.listConnections(Number(tripId))
.find((c) => c.provider_id === body.providerId);
const existing = (await this.config.listConnections(Number(tripId))).find(
(c) => c.provider_id === body.providerId,
);
// Only for the address the credential was stored against. Merging it into a
// probe of an arbitrary baseUrl turns this route into a way to have TREK
// post a stored API token at a server of the caller's choosing, which is
@@ -183,7 +186,7 @@ export class DocSyncController {
// reach for. Same host, same scheme, same port, or the caller types it in.
const sameTarget = !!existing && sameOrigin(existing.base_url, urlCheck.data.url);
const stored = sameTarget && existing ? this.config.toRef(existing) : null;
const fields = this.config.providerFields(body.providerId);
const fields = await this.config.providerFields(body.providerId);
const secrets: Record<string, string> = { ...(stored?.secrets ?? {}) };
const settings: Record<string, string> = { ...(stored?.settings ?? {}) };
for (const f of fields) {
@@ -203,7 +206,7 @@ export class DocSyncController {
allowInsecureTls: body.allowInsecureTls,
});
if (docFailed(res)) return { connected: false, error: res.error.code, detail: res.error.detail };
if (existing) this.config.recordProbe(existing.id, 'ok', null, res.data.capabilities);
if (existing) await this.config.recordProbe(existing.id, 'ok', null, res.data.capabilities);
return { connected: true, account: res.data.account, capabilities: res.data.capabilities };
}
@@ -214,15 +217,15 @@ export class DocSyncController {
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
await this.assertCanManage(tripId, user);
const conn = await this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
// Its bindings go with it (ON DELETE CASCADE), so read them first. Every
// subscription TREK registered for one of them is taken down while the
// credential is still here to do it with, as unbinding one does: left
// standing, a Paperless workflow or a Nextcloud listener keeps posting to
// a token that answers nothing, for good.
const unbound = this.config.listLinks(conn.trip_id).filter((l) => l.connection_id === conn.id);
const unbound = (await this.config.listLinks(conn.trip_id)).filter((l) => l.connection_id === conn.id);
const provider = this.registry.get(conn.provider_id);
if (provider?.unregisterWebhook) {
const ref = this.config.toRef(conn);
@@ -230,7 +233,7 @@ export class DocSyncController {
if (link.webhook_subscription_id) await provider.unregisterWebhook(ref, link.webhook_subscription_id);
}
}
this.config.deleteConnection(conn.id);
await this.config.deleteConnection(conn.id);
for (const link of unbound) this.announceBinding(link);
return { success: true };
}
@@ -245,8 +248,8 @@ export class DocSyncController {
@CurrentUser() user: User,
@Query('q') q?: string,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
await this.assertCanManage(tripId, user);
const conn = await this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
const provider = this.registry.get(conn.provider_id);
if (!provider) throw new HttpException('Provider not available', 400);
@@ -262,8 +265,8 @@ export class DocSyncController {
@CurrentUser() user: User,
@Body() body: DocsyncScopeCreateDto,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
await this.assertCanManage(tripId, user);
const conn = await this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
const provider = this.registry.get(conn.provider_id);
if (!provider) throw new HttpException('Provider not available', 400);
@@ -275,9 +278,10 @@ export class DocSyncController {
// ── Bindings ───────────────────────────────────────────────────────────────
@Get('links')
listLinks(@Param('tripId') tripId: string, @Req() req: Request) {
async listLinks(@Param('tripId') tripId: string, @Req() req: Request) {
const base = publicOrigin(req);
return this.config.listLinks(Number(tripId)).map((l) => this.config.publicLink(l, base));
const rows = await this.config.listLinks(Number(tripId));
return await Promise.all(rows.map((l) => this.config.publicLink(l, base)));
}
@Post('links')
@@ -288,8 +292,8 @@ export class DocSyncController {
@Body() body: DocsyncLinkDto,
@Req() req: Request,
) {
this.assertCanManage(tripId, user);
const res = this.config.createLink(Number(tripId), Number(user.id), body);
await this.assertCanManage(tripId, user);
const res = await this.config.createLink(Number(tripId), Number(user.id), body);
if (docFailed(res)) throw new HttpException(res.error.detail || res.error.code, 400);
this.announceBinding(res.data);
@@ -297,7 +301,7 @@ export class DocSyncController {
// not a failure of the binding (polling still carries it), so it is logged
// rather than thrown at the user; the first run that follows would write
// over anything put into the link state.
const conn = this.config.getConnection(res.data.connection_id);
const conn = await this.config.getConnection(res.data.connection_id);
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
if (conn && provider?.registerWebhook) {
const base = publicOrigin(req);
@@ -323,23 +327,23 @@ export class DocSyncController {
// A first run right away, so the user sees something happen instead of
// waiting out a poll interval and wondering whether it worked.
void this.sync.syncLink(res.data, { full: true });
return this.config.publicLink(this.config.getLink(res.data.id) ?? res.data, publicOrigin(req));
return await this.config.publicLink((await this.config.getLink(res.data.id)) ?? res.data, publicOrigin(req));
}
@Patch('links/:linkId')
@HttpCode(200)
updateLink(
async updateLink(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@CurrentUser() user: User,
@Body() body: DocsyncLinkUpdateDto,
@Req() req: Request,
) {
this.assertCanManage(tripId, user);
const link = this.config.getLink(Number(linkId));
await this.assertCanManage(tripId, user);
const link = await this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
const updated = this.config.updateLink(link.id, body);
return this.config.publicLink(updated ?? link, publicOrigin(req));
const updated = await this.config.updateLink(link.id, body);
return await this.config.publicLink(updated ?? link, publicOrigin(req));
}
@Delete('links/:linkId')
@@ -349,16 +353,16 @@ export class DocSyncController {
@Param('linkId') linkId: string,
@CurrentUser() user: User,
) {
this.assertCanManage(tripId, user);
const link = this.config.getLink(Number(linkId));
await this.assertCanManage(tripId, user);
const link = await this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
const conn = this.config.getConnection(link.connection_id);
const conn = await this.config.getConnection(link.connection_id);
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
if (conn && provider?.unregisterWebhook && link.webhook_subscription_id) {
await provider.unregisterWebhook(this.config.toRef(conn), link.webhook_subscription_id);
}
this.config.deleteLink(link.id);
await this.config.deleteLink(link.id);
this.announceBinding(link);
// Unbinding keeps both copies. Nothing is deleted anywhere.
return { success: true, documentsKept: true };
@@ -371,12 +375,12 @@ export class DocSyncController {
@Param('linkId') linkId: string,
@Body() body: DocsyncSyncNowDto,
) {
const link = this.config.getLink(Number(linkId));
const link = await this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
// An orphaned binding stays orphaned: its credential belongs to somebody who
// is no longer on this trip. The run itself would stand down as well; this
// says why, instead of answering with an empty run.
if (this.config.isOrphaned(link)) {
if (await this.config.isOrphaned(link)) {
throw new HttpException({ error: 'This binding lost its owner and has to be reconnected' }, 409);
}
// Refused before the shelved rows are touched, so a binding an admin
@@ -387,14 +391,14 @@ export class DocSyncController {
}
// A person asking for a run is also asking for the rows that gave up to be
// tried once more; the scheduler gets no such reprieve.
this.sync.retryShelvedItems(link.id);
await this.sync.retryShelvedItems(link.id);
return this.sync.syncLink(link, { full: body.full });
}
// ── Documents and conflicts ────────────────────────────────────────────────
@Get('items')
items(@Param('tripId') tripId: string, @Query('state') state?: string) {
async items(@Param('tripId') tripId: string, @Query('state') state?: string) {
const rows = state
? this.db.connection
.prepare(
@@ -421,7 +425,7 @@ export class DocSyncController {
@CurrentUser() user: User,
@Body() body: DocsyncResolveConflictDto,
) {
this.assertCanManage(tripId, user);
await this.assertCanManage(tripId, user);
const ok = await this.sync.resolveConflict(Number(itemId), body.keep, Number(tripId));
if (!ok) throw new HttpException('Item is not in conflict', 400);
return { success: true };
+8 -8
View File
@@ -50,9 +50,9 @@ export class DocSyncJob implements OnApplicationBootstrap {
/** When the last pass started; null until the first one, which is never skipped. */
private lastRunAt: number | null = null;
onApplicationBootstrap(): void {
async onApplicationBootstrap(): Promise<void> {
if (!this.registrar.isEnabled()) return;
logInfo(`Document sync: polling every ${this.intervalSeconds()}s`);
logInfo(`Document sync: polling every ${await this.intervalSeconds()}s`);
this.registrar.register('docsync', '* * * * *', () => this.tick());
}
@@ -64,16 +64,16 @@ export class DocSyncJob implements OnApplicationBootstrap {
* boundaries fall. Comparing against the last run keeps the setting's own
* resolution; the cost of the extra wake-ups is one read of app_settings.
*/
private isDue(now: number): boolean {
private async isDue(now: number): Promise<boolean> {
// Null rather than 0: comparing against the epoch means "due" only once the
// clock has passed the interval since 1970, which is true in production and
// false for any test that picks a small timestamp, a difference that would
// have hidden here rather than in the behaviour it is supposed to describe.
if (this.lastRunAt === null) return true;
return now - this.lastRunAt >= this.intervalSeconds() * 1000;
return now - this.lastRunAt >= (await this.intervalSeconds()) * 1000;
}
private intervalSeconds(): number {
private async intervalSeconds(): Promise<number> {
const raw = this.db.get<{ value: string }>('SELECT value FROM app_settings WHERE key = ?', SETTING_POLL_INTERVAL)?.value;
const parsed = Number.parseInt(raw || '', 10);
if (!Number.isFinite(parsed)) return DEFAULT_POLL_INTERVAL_SECONDS;
@@ -89,15 +89,15 @@ export class DocSyncJob implements OnApplicationBootstrap {
if (killSwitch === 'false') return;
const now = Date.now();
if (!this.isDue(now)) return;
if (!(await this.isDue(now))) return;
this.lastRunAt = now;
// Cheap, and it catches a binding whose owner left the trip through a
// path that has no hook to attach to: a transfer, a direct DB edit.
const orphaned = this.config.markOrphanedLinks();
const orphaned = await this.config.markOrphanedLinks();
if (orphaned > 0) logInfo(`Document sync: ${orphaned} link(s) orphaned, owner no longer on the trip`);
const links = this.sync.dueLinks();
const links = await this.sync.dueLinks();
for (const link of links) {
// One failing provider must not stop the others: an unreachable NAS on
// one trip is not a reason to skip a Paperless binding on another.
+5 -5
View File
@@ -69,9 +69,9 @@ export class DocSyncMcp {
})
async listIssues({ tripId }: { tripId: number }, ctx: McpContext) {
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const links = this.config.listLinks(tripId);
const links = await this.config.listLinks(tripId);
if (links.length === 0) return ok({ configured: false, issues: [] });
return ok({ configured: true, issues: this.sync.issues(tripId) });
return ok({ configured: true, issues: await this.sync.issues(tripId) });
}
@Tool({
@@ -92,7 +92,7 @@ export class DocSyncMcp {
})
async syncNow({ tripId, full }: { tripId: number; full?: boolean }, ctx: McpContext) {
if (!(await this.files.verifyTripAccess(tripId, ctx.userId))) return noAccess();
const links = this.config.listLinks(tripId);
const links = await this.config.listLinks(tripId);
if (links.length === 0) {
return errorResult('This trip is not connected to a document store. Connect one in the trip\'s file manager first.');
}
@@ -101,7 +101,7 @@ export class DocSyncMcp {
// An orphaned binding stays stopped, as it does on the REST route and for
// the scheduler: its credential belongs to somebody who has left the trip,
// and a run would use it anyway.
if (this.config.isOrphaned(link)) {
if (await this.config.isOrphaned(link)) {
results.push({ linkId: link.id, provider: link.provider_id, state: 'orphaned', errorCode: 'orphaned' });
continue;
}
@@ -116,7 +116,7 @@ export class DocSyncMcp {
// that were shelved after too many failures. The REST route does the
// same thing before its run; a tool that skipped it would answer "in
// sync" while leaving them shelved.
this.sync.retryShelvedItems(link.id);
await this.sync.retryShelvedItems(link.id);
results.push({ linkId: link.id, provider: link.provider_id, ...(await this.sync.syncLink(link, { full: full === true })) });
}
if (results.every((r) => r.errorCode === PROVIDER_DISABLED)) {
+43 -43
View File
@@ -109,7 +109,7 @@ export class DocSyncService {
* than stood down per run. It never runs, so it never gets a `last_sync_at`,
* and twenty of them would sort first on every tick and take every slot.
*/
dueLinks(limit = 20): LinkRow[] {
async dueLinks(limit = 20): Promise<LinkRow[]> {
return this.db.connection
.prepare(
`SELECT * FROM trip_document_links
@@ -137,7 +137,7 @@ export class DocSyncService {
*/
async isSwitchedOff(link: Pick<LinkRow, 'provider_id'>): Promise<boolean> {
if (!(await this.addons.isAddonEnabled(ADDON_IDS.DOCUMENTS))) return true;
return !this.config.enabledProviderIds().includes(link.provider_id);
return !(await this.config.enabledProviderIds()).includes(link.provider_id);
}
/**
@@ -163,7 +163,7 @@ export class DocSyncService {
return await this.runLink(link, opts);
} catch (err) {
this.logger.error(`link ${link.id} failed: ${err instanceof Error ? err.message : String(err)}`);
this.recordLinkFailure(link, 'unknown');
await this.recordLinkFailure(link, 'unknown');
return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'unknown' };
} finally {
this.running.delete(link.id);
@@ -182,17 +182,17 @@ export class DocSyncService {
// An orphaned binding runs for nobody. Its credential belongs to somebody
// who has left the trip, and a webhook or a resolved conflict reach this
// without asking beforehand.
if (this.config.isOrphaned(link)) {
if (await this.config.isOrphaned(link)) {
return { state: 'orphaned', pulled: 0, pushed: 0, conflicts: 0, missing: 0 };
}
const conn = this.config.getConnection(link.connection_id);
const conn = await this.config.getConnection(link.connection_id);
if (!conn) {
this.recordLinkFailure(link, 'not_found');
await this.recordLinkFailure(link, 'not_found');
return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'not_found' };
}
const provider = this.registry.get(link.provider_id);
if (!provider) {
this.recordLinkFailure(link, 'provider_error');
await this.recordLinkFailure(link, 'provider_error');
return { state: 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: 'provider_error' };
}
@@ -204,20 +204,20 @@ export class DocSyncService {
const resolved = await provider.resolveScope(ref, scope);
if (docFailed(resolved)) {
const code = resolved.error.code === 'not_found' || resolved.error.code === 'scope_missing' ? 'scope_missing' : resolved.error.code;
this.recordLinkFailure(link, code, code === 'scope_missing' ? 'scope_lost' : undefined);
await this.recordLinkFailure(link, code, code === 'scope_missing' ? 'scope_lost' : undefined);
return { state: code === 'scope_missing' ? 'scope_lost' : 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: code };
}
// Read before the listing because they decide how much of it is needed: a
// file restored in TREK waits for a listing that shows its copy.
const items = this.loadItems(link.id);
const local = this.loadLocalDocuments(link);
const items = await this.loadItems(link.id);
const local = await this.loadLocalDocuments(link);
if (opts.full || needsFullListing(items, local)) scope.cursor = null;
const listing = await provider.list(ref, scope);
if (docFailed(listing)) {
const state = listing.error.code === 'unauthorized' ? 'needs_reauth' : 'failed';
this.recordLinkFailure(link, listing.error.code, state);
await this.recordLinkFailure(link, listing.error.code, state);
return { state, pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: listing.error.code };
}
@@ -237,7 +237,7 @@ export class DocSyncService {
// Refusing the whole run is the point: the listing is not trustworthy, so
// nothing in it should be acted on, not even the parts that look fine.
this.logger.warn(`link ${link.id}: mass-delete guard tripped (${plan.missingCount} of ${items.length} gone), run abandoned`);
this.recordLinkFailure(link, 'mass_delete_guard', 'partial');
await this.recordLinkFailure(link, 'mass_delete_guard', 'partial');
return { state: 'partial', pulled: 0, pushed: 0, conflicts: 0, missing: plan.missingCount, errorCode: 'mass_delete_guard' };
}
@@ -283,7 +283,7 @@ export class DocSyncService {
* clean.
*/
const cursor = state === 'ok' && !wroteUpstream ? listing.data.cursor : null;
this.recordLinkSuccess(link, cursor, state, softFailure ?? null);
await this.recordLinkSuccess(link, cursor, state, softFailure ?? null);
if (pulled > 0 || pushed > 0) {
this.realtime.broadcast(link.trip_id, 'docsync:changed', { linkId: link.id, pulled, pushed });
}
@@ -468,17 +468,17 @@ export class DocSyncService {
// when it arrives through the store instead.
const allowed = isVideoExtension(path.extname(name)) || isAllowedByOperator(name, await this.allowedTypes.get());
if (isBlockedName(name) || !allowed) {
this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' });
await this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' });
return 'unsupported_type';
}
if (remote.size !== null && remote.size > MAX_FILE_SIZE) {
this.upsertItem(ctx.link, { itemId, remote, state: 'too_large', errorCode: 'too_large' });
await this.upsertItem(ctx.link, { itemId, remote, state: 'too_large', errorCode: 'too_large' });
return 'too_large';
}
const fetched = await ctx.provider.fetch(ctx.ref, ctx.scope, remote.remoteId);
if (docFailed(fetched)) {
this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: fetched.error.code });
await this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: fetched.error.code });
return fetched.error.code;
}
@@ -506,7 +506,7 @@ export class DocSyncService {
} catch (err) {
await fs.promises.rm(tmpPath, { force: true });
const code: DocsyncErrorCode = err instanceof Error && err.message === 'too_large' ? 'too_large' : 'provider_error';
this.upsertItem(ctx.link, { itemId, remote, state: code === 'too_large' ? 'too_large' : 'error', errorCode: code });
await this.upsertItem(ctx.link, { itemId, remote, state: code === 'too_large' ? 'too_large' : 'error', errorCode: code });
return code;
}
@@ -531,7 +531,7 @@ export class DocSyncService {
// here would read it as TREK's own and send the old name back up.
if (superseded && !superseded.deleted_at && pairing?.content_sha256 === sha256) {
await fs.promises.rm(tmpPath, { force: true });
this.upsertItem(ctx.link, {
await this.upsertItem(ctx.link, {
itemId,
remote,
state: 'synced',
@@ -545,7 +545,7 @@ export class DocSyncService {
await this.storage.put('files', storageKey, { tmpPath });
} catch {
await fs.promises.rm(tmpPath, { force: true });
this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: 'provider_error' });
await this.upsertItem(ctx.link, { itemId, remote, state: 'error', errorCode: 'provider_error' });
return 'provider_error';
}
@@ -568,7 +568,7 @@ export class DocSyncService {
{ filename: storageKey, originalname: name, size: bytes, mimetype: remote.mimeType || 'application/octet-stream' },
// Attributed to the person whose connection brought it in, which is the
// only honest answer: nobody in TREK uploaded it.
this.config.getConnection(ctx.link.connection_id)?.owner_user_id ?? 0,
(await this.config.getConnection(ctx.link.connection_id))?.owner_user_id ?? 0,
{
place_id: superseded?.place_id ?? null,
reservation_id: superseded?.reservation_id ?? null,
@@ -587,7 +587,7 @@ export class DocSyncService {
.run(file.id, supersededId);
await this.files.softDeleteFile(supersededId as number);
}
this.upsertItem(ctx.link, {
await this.upsertItem(ctx.link, {
itemId,
remote,
state: 'synced',
@@ -618,11 +618,11 @@ export class DocSyncService {
// Paperless refuses anything outside its parser list with a 400. Checking
// first turns a recurring hard failure into one visible row that says why.
if (caps.acceptedMimeTypes && !caps.acceptedMimeTypes.includes(mime)) {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'rejected_type', errorCode: 'unsupported_type' });
await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'rejected_type', errorCode: 'unsupported_type' });
return 'unsupported_type';
}
if (caps.maxUploadBytes !== null && local.size > caps.maxUploadBytes) {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'too_large', errorCode: 'too_large' });
await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'too_large', errorCode: 'too_large' });
return 'too_large';
}
@@ -644,11 +644,11 @@ export class DocSyncService {
const got = await this.storage.getStream('files', String(file.filename));
stream = got.stream;
} catch {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: 'provider_error' });
await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: 'provider_error' });
return 'provider_error';
}
const uid = this.existingUid(ctx.link.id, itemId) ?? newTrekDocUid();
const uid = (await this.existingUid(ctx.link.id, itemId)) ?? newTrekDocUid();
const res = await ctx.provider.push(ctx.ref, ctx.scope, {
body: stream,
fileName: local.name,
@@ -662,7 +662,7 @@ export class DocSyncService {
});
if (docFailed(res)) {
this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: res.error.code, trekDocUid: uid });
await this.upsertItem(ctx.link, { itemId, fileId: local.fileId, state: 'error', errorCode: res.error.code, trekDocUid: uid });
return res.error.code;
}
@@ -672,9 +672,9 @@ export class DocSyncService {
// the unique index on (link_id, remote_id) would abort this run (and every
// run after it) with a constraint error. The upload happened, so nothing is
// lost; what must not happen is the pairing moving off the file that owns it.
const heldBy = this.pairingOwner(ctx.link.id, res.data.remoteId, itemId);
const heldBy = await this.pairingOwner(ctx.link.id, res.data.remoteId, itemId);
if (heldBy !== null) {
this.upsertItem(ctx.link, {
await this.upsertItem(ctx.link, {
itemId,
fileId: local.fileId,
state: 'error',
@@ -684,7 +684,7 @@ export class DocSyncService {
return 'conflict';
}
this.upsertItem(ctx.link, {
await this.upsertItem(ctx.link, {
itemId,
fileId: local.fileId,
state: 'synced',
@@ -718,7 +718,7 @@ export class DocSyncService {
* and the only way to find out is to try again. The scheduler never calls
* this: automatic retries are exactly what the limit exists to stop.
*/
retryShelvedItems(linkId: number): void {
async retryShelvedItems(linkId: number): Promise<void> {
this.db.connection
.prepare(
`UPDATE document_sync_items
@@ -730,7 +730,7 @@ export class DocSyncService {
// ── State loading and writing ──────────────────────────────────────────────
private loadItems(linkId: number): SyncItemState[] {
private async loadItems(linkId: number): Promise<SyncItemState[]> {
const rows = this.db.connection
.prepare(
`SELECT id, file_id, trek_doc_uid, remote_id, remote_version, remote_name, remote_size, remote_modified_at,
@@ -771,7 +771,7 @@ export class DocSyncService {
* Paperless, everything else to Nextcloud": splitting a trip across bindings
* would need a per-document assignment that nothing in the UI offers yet.
*/
private loadLocalDocuments(link: LinkRow): LocalDocument[] {
private async loadLocalDocuments(link: LinkRow): Promise<LocalDocument[]> {
const rows = this.db.connection
.prepare(
`SELECT f.id, f.original_name, f.file_size, f.mime_type, f.deleted_at
@@ -797,7 +797,7 @@ export class DocSyncService {
}
/** The item already paired with this remote document, if it is a different one. */
private pairingOwner(linkId: number, remoteId: string, itemId: number | null): number | null {
private async pairingOwner(linkId: number, remoteId: string, itemId: number | null): Promise<number | null> {
const row = this.db.connection
.prepare('SELECT id FROM document_sync_items WHERE link_id = ? AND remote_id = ?')
.get(linkId, remoteId) as { id?: number } | undefined;
@@ -805,7 +805,7 @@ export class DocSyncService {
return itemId !== null && Number(row.id) === Number(itemId) ? null : Number(row.id);
}
private existingUid(linkId: number, itemId: number | null): string | null {
private async existingUid(linkId: number, itemId: number | null): Promise<string | null> {
if (itemId === null) return null;
const row = this.db.connection
.prepare('SELECT trek_doc_uid FROM document_sync_items WHERE id = ? AND link_id = ?')
@@ -820,7 +820,7 @@ export class DocSyncService {
return hash.digest('hex');
}
private upsertItem(
private async upsertItem(
link: LinkRow,
patch: {
itemId: number | null;
@@ -839,7 +839,7 @@ export class DocSyncService {
pushedSha256?: string;
trekDocUid?: string;
},
): void {
): Promise<void> {
const failed = patch.state === 'error';
/**
* A failed transfer leaves the pairing describing the copy TREK holds.
@@ -946,7 +946,7 @@ export class DocSyncService {
);
}
private recordLinkSuccess(link: LinkRow, cursor: string | null, state: string, errorCode: string | null): void {
private async recordLinkSuccess(link: LinkRow, cursor: string | null, state: string, errorCode: string | null): Promise<void> {
this.db.connection
.prepare(
`UPDATE trip_document_links
@@ -957,7 +957,7 @@ export class DocSyncService {
.run(cursor, state, errorCode, link.id);
}
private recordLinkFailure(link: LinkRow, code: string, state = 'failed'): void {
private async recordLinkFailure(link: LinkRow, code: string, state = 'failed'): Promise<void> {
const failures = link.failure_count + 1;
const wait = backoffSeconds(LINK_BACKOFF_SECONDS, failures);
this.db.connection
@@ -998,7 +998,7 @@ export class DocSyncService {
* next run to sort out rather than the owner's choice being refused.
*/
private async renameRemoteTo(link: LinkRow, remoteId: string, name: string, itemId: number): Promise<void> {
const conn = this.config.getConnection(link.connection_id);
const conn = await this.config.getConnection(link.connection_id);
const provider = this.registry.get(link.provider_id);
if (!conn || !provider) return;
const res = await provider.rename(this.config.toRef(conn), this.config.toScopeRef(link), remoteId, name);
@@ -1017,7 +1017,7 @@ export class DocSyncService {
.get(itemId) as Record<string, unknown> | undefined;
if (!item || item.state !== 'conflict') return false;
if (tripId !== undefined && Number(item.trip_id) !== Number(tripId)) return false;
const link = this.config.getLink(Number(item.link_id));
const link = await this.config.getLink(Number(item.link_id));
if (!link) return false;
/**
@@ -1087,7 +1087,7 @@ export class DocSyncService {
* that vanished upstream. Deliberately not "everything not synced": a row
* waiting for its turn is not a problem anyone should be shown.
*/
issues(tripId: number): Array<Record<string, unknown>> {
async issues(tripId: number): Promise<Array<Record<string, unknown>>> {
return this.db.connection
.prepare(
`SELECT i.id, i.state, i.error_code, i.remote_name, i.remote_missing_at, f.original_name AS file_name
@@ -1103,7 +1103,7 @@ export class DocSyncService {
/** Per-trip view for the UI: what is synced, what needs attention. */
async status(tripId: number): Promise<Record<string, unknown>> {
const links = this.config.listLinks(tripId);
const links = await this.config.listLinks(tripId);
const counts = this.db.connection
.prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state')
.all(tripId) as Array<{ state: string; n: number }>;
@@ -1146,7 +1146,7 @@ export class DocSyncService {
for (const l of links) {
const row = byLink.get(l.id);
linkCards.push({
...this.config.publicLink(l, null),
...(await this.config.publicLink(l, null)),
// Paused rather than failed, so the card can say why nothing moves
// without the binding's own state being touched.
providerOff: await this.isSwitchedOff(l),
@@ -88,7 +88,7 @@ export interface DocumentConnectionRef {
* stored but never changes it: the values on screen may be for another
* account, or never be saved at all.
*/
saveSecret?: (key: string, value: string | null) => void;
saveSecret?: (key: string, value: string | null) => Promise<void>;
}
/** The container at the provider that one trip is bound to. */
@@ -228,7 +228,7 @@ export interface SynologyDriveCreds {
* connection passes one; a probe of form values uses the stored token without
* being able to change it.
*/
saveDeviceToken?: (stored: string | null) => void;
saveDeviceToken?: (stored: string | null) => Promise<void>;
allowInsecureTls: boolean;
}
@@ -554,7 +554,7 @@ export class SynologyDriveClient {
* anything is written, so a change is written once and not on every call of
* the run that made it.
*/
private deviceSlot(creds: SynologyDriveCreds): DeviceTokenSlot | null {
private async deviceSlot(creds: SynologyDriveCreds): Promise<DeviceTokenSlot | null> {
if (creds.connectionId <= 0) return null;
const key = `${creds.connectionId}\u0000${creds.connectionCreatedAt}`;
const account = this.accountDigest(creds);
@@ -563,24 +563,24 @@ export class SynologyDriveClient {
slot = { account, token: deviceTokenFor(creds.storedDeviceToken, account) };
this.deviceTokens.set(key, slot);
}
this.persist(creds, slot);
await this.persist(creds, slot);
return slot;
}
private persist(creds: SynologyDriveCreds, slot: DeviceTokenSlot): void {
private async persist(creds: SynologyDriveCreds, slot: DeviceTokenSlot): Promise<void> {
if (!creds.saveDeviceToken) return;
if (slot.stored === undefined) slot.stored = creds.storedDeviceToken ?? null;
const wanted = slot.token === null ? null : storedDeviceToken(slot.account, slot.token);
if (wanted === slot.stored) return;
creds.saveDeviceToken(wanted);
await creds.saveDeviceToken(wanted);
slot.stored = wanted;
}
private setDeviceToken(creds: SynologyDriveCreds, token: string | null): void {
const slot = this.deviceSlot(creds);
private async setDeviceToken(creds: SynologyDriveCreds, token: string | null): Promise<void> {
const slot = await this.deviceSlot(creds);
if (!slot || slot.token === token) return;
slot.token = token;
this.persist(creds, slot);
await this.persist(creds, slot);
}
private async request(
@@ -691,8 +691,8 @@ export class SynologyDriveClient {
private async session(creds: SynologyDriveCreds): Promise<SynoSession> {
const cached = this.sessions.get(this.sessionKey(creds));
if (cached && Date.now() - cached.createdAt < SESSION_MAX_AGE_MS) {
const slot = this.deviceSlot(creds);
if (slot && slot.token === null && cached.deviceId) this.setDeviceToken(creds, cached.deviceId);
const slot = await this.deviceSlot(creds);
if (slot && slot.token === null && cached.deviceId) await this.setDeviceToken(creds, cached.deviceId);
return cached;
}
return await this.login(creds);
@@ -736,7 +736,7 @@ export class SynologyDriveClient {
throw new SynologyDriveError('provider_error', 'The NAS accepted the login but returned no session id');
}
const issuedDeviceId = isRecord(data) && typeof data.did === 'string' ? data.did : deviceId;
if (issuedDeviceId) this.setDeviceToken(creds, issuedDeviceId);
if (issuedDeviceId) await this.setDeviceToken(creds, issuedDeviceId);
const session: SynoSession = {
sid,
synoToken: isRecord(data) && typeof data.synotoken === 'string' ? data.synotoken : null,
@@ -761,7 +761,7 @@ export class SynologyDriveClient {
* is what the lockout records.
*/
private async authenticate(creds: SynologyDriveCreds): Promise<{ data: unknown; deviceId: string | null }> {
const deviceId = this.deviceSlot(creds)?.token ?? null;
const deviceId = (await this.deviceSlot(creds))?.token ?? null;
try {
return { data: await this.call(creds, this.loginParams(creds, deviceId), { cgi: AUTH_CGI }), deviceId };
} catch (error: unknown) {
@@ -769,7 +769,7 @@ export class SynologyDriveClient {
throw error;
}
// Unless another login replaced it while this one was out.
if (this.deviceSlot(creds)?.token === deviceId) this.setDeviceToken(creds, null);
if ((await this.deviceSlot(creds))?.token === deviceId) await this.setDeviceToken(creds, null);
if (!creds.otpCode) throw error;
return { data: await this.call(creds, this.loginParams(creds, null), { cgi: AUTH_CGI }), deviceId: null };
}
+1 -1
View File
@@ -417,7 +417,7 @@ export class TripsService {
`).run(newTitle, newDesc, newStart || null, newEnd || null, newCurrency, newArchived, newCover, newReminder, tripId);
if (trip.start_date && trip.end_date && newStart && newStart !== trip.start_date)
this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
await this.vacay.shiftOwnerEntriesForTripWindow(trip.user_id, trip.start_date, trip.end_date, newStart);
if (regenerate) {
await this.uow.transactional(async () => {
+67 -67
View File
@@ -60,32 +60,32 @@ export class VacayController {
@Put('plan')
async updatePlan(@CurrentUser() user: User, @Body() body: VacayUpdatePlanDto, @Headers('x-socket-id') socketId?: string) {
const planId = this.vacay.getActivePlanId(user.id);
const planId = await this.vacay.getActivePlanId(user.id);
return this.vacay.updatePlan(planId, body, socketId);
}
@Post('plan/holiday-calendars')
@HttpCode(200)
addHolidayCalendar(
async addHolidayCalendar(
@CurrentUser() user: User,
@Body() body: VacayAddHolidayCalendarDto,
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
const calendar = this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId, body.type);
const planId = await this.vacay.getActivePlanId(user.id);
const calendar = await this.vacay.addHolidayCalendar(planId, body.region, body.label ?? null, body.color, body.sort_order, socketId, body.type);
return { calendar };
}
@Put('plan/holiday-calendars/:id')
updateHolidayCalendar(
async updateHolidayCalendar(
@CurrentUser() user: User,
@Param('id') idParam: string,
@Body() body: VacayUpdateHolidayCalendarDto,
@Headers('x-socket-id') socketId?: string,
) {
const id = Number.parseInt(idParam);
const planId = this.vacay.getActivePlanId(user.id);
const calendar = this.vacay.updateHolidayCalendar(id, planId, body, socketId);
const planId = await this.vacay.getActivePlanId(user.id);
const calendar = await this.vacay.updateHolidayCalendar(id, planId, body, socketId);
if (!calendar) {
throw new HttpException({ error: 'Calendar not found' }, 404);
}
@@ -93,38 +93,38 @@ export class VacayController {
}
@Delete('plan/holiday-calendars/:id')
deleteHolidayCalendar(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
async deleteHolidayCalendar(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
const id = Number.parseInt(idParam);
const planId = this.vacay.getActivePlanId(user.id);
if (!this.vacay.deleteHolidayCalendar(id, planId, socketId)) {
const planId = await this.vacay.getActivePlanId(user.id);
if (!(await this.vacay.deleteHolidayCalendar(id, planId, socketId))) {
throw new HttpException({ error: 'Calendar not found' }, 404);
}
return { success: true };
}
@Put('color')
setColor(
async setColor(
@CurrentUser() user: User,
@Body() body: VacaySetColorDto,
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
const planId = await this.vacay.getActivePlanId(user.id);
const userId = body.target_user_id ? Number.parseInt(String(body.target_user_id)) : user.id;
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === userId)) {
if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === userId)) {
throw new HttpException({ error: 'User not in plan' }, 403);
}
this.vacay.setUserColor(userId, planId, body.color, socketId);
await this.vacay.setUserColor(userId, planId, body.color, socketId);
return { success: true };
}
@Post('invite')
@HttpCode(200)
invite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
async invite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
if (!body.user_id) {
throw new HttpException({ error: 'user_id required' }, 400);
}
const plan = this.vacay.getActivePlan(user.id);
const result = this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number);
const plan = await this.vacay.getActivePlan(user.id);
const result = await this.vacay.sendInvite(plan.id, user.id, user.username, user.email, body.user_id as number);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -133,8 +133,8 @@ export class VacayController {
@Post('invite/accept')
@HttpCode(200)
acceptInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
const result = this.vacay.acceptInvite(user.id, body.plan_id as number, socketId);
async acceptInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
const result = await this.vacay.acceptInvite(user.id, body.plan_id as number, socketId);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -143,71 +143,71 @@ export class VacayController {
@Post('invite/decline')
@HttpCode(200)
declineInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
this.vacay.declineInvite(user.id, body.plan_id as number, socketId);
async declineInvite(@CurrentUser() user: User, @Body() body: VacayInviteActionDto, @Headers('x-socket-id') socketId?: string) {
await this.vacay.declineInvite(user.id, body.plan_id as number, socketId);
return { success: true };
}
@Post('invite/cancel')
@HttpCode(200)
cancelInvite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
const plan = this.vacay.getActivePlan(user.id);
this.vacay.cancelInvite(plan.id, body.user_id as number);
async cancelInvite(@CurrentUser() user: User, @Body() body: VacayInviteDto) {
const plan = await this.vacay.getActivePlan(user.id);
await this.vacay.cancelInvite(plan.id, body.user_id as number);
return { success: true };
}
@Post('dissolve')
@HttpCode(200)
dissolve(@CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) {
this.vacay.dissolvePlan(user.id, socketId);
async dissolve(@CurrentUser() user: User, @Headers('x-socket-id') socketId?: string) {
await this.vacay.dissolvePlan(user.id, socketId);
return { success: true };
}
@Get('available-users')
availableUsers(@CurrentUser() user: User) {
const planId = this.vacay.getActivePlanId(user.id);
return { users: this.vacay.getAvailableUsers(user.id, planId) };
async availableUsers(@CurrentUser() user: User) {
const planId = await this.vacay.getActivePlanId(user.id);
return { users: await this.vacay.getAvailableUsers(user.id, planId) };
}
@Get('years')
years(@CurrentUser() user: User) {
const planId = this.vacay.getActivePlanId(user.id);
return { years: this.vacay.listYears(planId) };
async years(@CurrentUser() user: User) {
const planId = await this.vacay.getActivePlanId(user.id);
return { years: await this.vacay.listYears(planId) };
}
@Post('years')
@HttpCode(200)
addYear(@CurrentUser() user: User, @Body() body: VacayAddYearDto, @Headers('x-socket-id') socketId?: string) {
async addYear(@CurrentUser() user: User, @Body() body: VacayAddYearDto, @Headers('x-socket-id') socketId?: string) {
if (!body.year) {
throw new HttpException({ error: 'Year required' }, 400);
}
const planId = this.vacay.getActivePlanId(user.id);
return { years: this.vacay.addYear(planId, body.year as number, socketId) };
const planId = await this.vacay.getActivePlanId(user.id);
return { years: await this.vacay.addYear(planId, body.year as number, socketId) };
}
@Delete('years/:year')
deleteYear(@CurrentUser() user: User, @Param('year') yearParam: string, @Headers('x-socket-id') socketId?: string) {
async deleteYear(@CurrentUser() user: User, @Param('year') yearParam: string, @Headers('x-socket-id') socketId?: string) {
const year = Number.parseInt(yearParam);
const planId = this.vacay.getActivePlanId(user.id);
return { years: this.vacay.deleteYear(planId, year, socketId) };
const planId = await this.vacay.getActivePlanId(user.id);
return { years: await this.vacay.deleteYear(planId, year, socketId) };
}
@Get('year-settings')
yearSettings(@CurrentUser() user: User) {
return { settings: this.vacay.getYearSettings(user.id) };
async yearSettings(@CurrentUser() user: User) {
return { settings: await this.vacay.getYearSettings(user.id) };
}
@Put('year-settings')
updateYearSettings(
async updateYearSettings(
@CurrentUser() user: User,
@Body() body: VacayYearSettingsDto,
) {
return { settings: this.vacay.updateYearSettings(user.id, body) };
return { settings: await this.vacay.updateYearSettings(user.id, body) };
}
@Get('entries/:year')
entries(@CurrentUser() user: User, @Param('year') year: string) {
const planId = this.vacay.getActivePlanId(user.id);
async entries(@CurrentUser() user: User, @Param('year') year: string) {
const planId = await this.vacay.getActivePlanId(user.id);
// Entries load over the caller's leave-year window (#737), so a shifted year
// returns both calendar halves the grid renders.
return this.vacay.getEntries(planId, year, user.id);
@@ -215,21 +215,21 @@ export class VacayController {
@Post('entries/toggle')
@HttpCode(200)
toggleEntry(
async toggleEntry(
@CurrentUser() user: User,
@Body() body: VacayToggleEntryDto,
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
const planId = await this.vacay.getActivePlanId(user.id);
let userId = user.id;
if (body.target_user_id && Number.parseInt(String(body.target_user_id)) !== user.id) {
const tid = Number.parseInt(String(body.target_user_id));
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === tid)) {
if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === tid)) {
throw new HttpException({ error: 'User not in plan' }, 403);
}
userId = tid;
}
const result = this.vacay.toggleEntry(userId, planId, body.date, body.fraction, body.kind, socketId);
const result = await this.vacay.toggleEntry(userId, planId, body.date, body.fraction, body.kind, socketId);
if (result.error === 'weekend_blocked') {
throw new HttpException({ error: 'Weekend days are blocked on this plan' }, 400);
}
@@ -238,36 +238,36 @@ export class VacayController {
@Post('entries/company-holiday')
@HttpCode(200)
companyHoliday(
async companyHoliday(
@CurrentUser() user: User,
@Body() body: VacayCompanyHolidayDto,
@Headers('x-socket-id') socketId?: string,
) {
const planId = this.vacay.getActivePlanId(user.id);
const planId = await this.vacay.getActivePlanId(user.id);
return this.vacay.toggleCompanyHoliday(planId, body.date, body.note, socketId);
}
@Get('stats/:year')
stats(@CurrentUser() user: User, @Param('year') yearParam: string) {
async stats(@CurrentUser() user: User, @Param('year') yearParam: string) {
const year = Number.parseInt(yearParam);
const planId = this.vacay.getActivePlanId(user.id);
return { stats: this.vacay.getStats(planId, year) };
const planId = await this.vacay.getActivePlanId(user.id);
return { stats: await this.vacay.getStats(planId, year) };
}
@Put('stats/:year')
updateStats(
async updateStats(
@CurrentUser() user: User,
@Param('year') yearParam: string,
@Body() body: VacayUpdateStatsDto,
@Headers('x-socket-id') socketId?: string,
) {
const year = Number.parseInt(yearParam);
const planId = this.vacay.getActivePlanId(user.id);
const planId = await this.vacay.getActivePlanId(user.id);
const userId = body.target_user_id ? Number.parseInt(String(body.target_user_id)) : user.id;
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === userId)) {
if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === userId)) {
throw new HttpException({ error: 'User not in plan' }, 403);
}
this.vacay.updateStats(userId, planId, year, body.vacation_days as number, socketId);
await this.vacay.updateStats(userId, planId, year, body.vacation_days as number, socketId);
return { success: true };
}
@@ -278,7 +278,7 @@ export class VacayController {
@Post('shares')
@HttpCode(200)
share(
async share(
@CurrentUser() user: User,
@Body() body: VacayShareDto,
@Headers('x-socket-id') socketId?: string,
@@ -286,7 +286,7 @@ export class VacayController {
if (!body.user_id) {
throw new HttpException({ error: 'user_id required' }, 400);
}
const result = this.vacay.shareCalendar(user.id, user.email, Number.parseInt(String(body.user_id)), socketId);
const result = await this.vacay.shareCalendar(user.id, user.email, Number.parseInt(String(body.user_id)), socketId);
if (result.error) {
throw new HttpException({ error: result.error }, result.status!);
}
@@ -294,31 +294,31 @@ export class VacayController {
}
@Get('shares/available-users')
shareAvailableUsers(@CurrentUser() user: User) {
return { users: this.vacay.getShareAvailableUsers(user.id) };
async shareAvailableUsers(@CurrentUser() user: User) {
return { users: await this.vacay.getShareAvailableUsers(user.id) };
}
@Get('shares/calendars/:year')
sharedCalendars(@CurrentUser() user: User, @Param('year') year: string) {
return { calendars: this.vacay.getSharedCalendars(user.id, year) };
async sharedCalendars(@CurrentUser() user: User, @Param('year') year: string) {
return { calendars: await this.vacay.getSharedCalendars(user.id, year) };
}
@Put('shares/:id')
updateShare(
async updateShare(
@CurrentUser() user: User,
@Param('id') idParam: string,
@Body() body: VacayShareUpdateDto,
@Headers('x-socket-id') socketId?: string,
) {
if (!this.vacay.setShareHidden(Number.parseInt(idParam), user.id, body.hidden, socketId)) {
if (!(await this.vacay.setShareHidden(Number.parseInt(idParam), user.id, body.hidden, socketId))) {
throw new HttpException({ error: 'Share not found' }, 404);
}
return { success: true };
}
@Delete('shares/:id')
deleteShare(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
if (!this.vacay.removeShare(Number.parseInt(idParam), user.id, socketId)) {
async deleteShare(@CurrentUser() user: User, @Param('id') idParam: string, @Headers('x-socket-id') socketId?: string) {
if (!(await this.vacay.removeShare(Number.parseInt(idParam), user.id, socketId))) {
throw new HttpException({ error: 'Share not found' }, 404);
}
return { success: true };
+46 -46
View File
@@ -92,7 +92,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getVacayPlan(_args: Record<string, never>, ctx: McpContext) {
const plan = this.vacay.getPlanData(ctx.userId);
const plan = await this.vacay.getPlanData(ctx.userId);
return ok({ plan });
}
@@ -105,7 +105,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getVacayYearSettings(_args: Record<string, never>, ctx: McpContext) {
return ok({ settings: this.vacay.getYearSettings(ctx.userId) });
return ok({ settings: await this.vacay.getYearSettings(ctx.userId) });
}
@Tool({
@@ -128,7 +128,7 @@ export class VacayMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
// updatePlan already returns the fully-hydrated { plan }; surface it so the
// AI consumer sees the updated plan, matching get_vacay_plan.
const result = await this.vacay.updatePlan(planId, {
@@ -150,8 +150,8 @@ export class VacayMcp {
})
async setVacayColor({ color }: { color: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.setUserColor(ctx.userId, planId, color, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.setUserColor(ctx.userId, planId, color, undefined);
// Echo the persisted color (mirrors the service default) so the AI consumer sees what was set.
return ok({ success: true, color: color || '#6366f1' });
}
@@ -165,8 +165,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getAvailableVacayUsers(_args: Record<string, never>, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const users = this.vacay.getAvailableUsers(ctx.userId, planId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const users = await this.vacay.getAvailableUsers(ctx.userId, planId);
return ok({ users });
}
@@ -182,10 +182,10 @@ export class VacayMcp {
})
async sendVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const me = await this.auth.getCurrentUser(ctx.userId);
if (!me) return errorResult('User not found.');
const result = this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId);
const result = await this.vacay.sendInvite(planId, ctx.userId, me.username, me.email, targetUserId);
if (result.error) return errorResult(result.error);
return ok({ success: true });
}
@@ -202,7 +202,7 @@ export class VacayMcp {
})
async acceptVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const result = this.vacay.acceptInvite(ctx.userId, planId, undefined);
const result = await this.vacay.acceptInvite(ctx.userId, planId, undefined);
if (result.error) return errorResult(result.error);
return ok({ success: true });
}
@@ -219,7 +219,7 @@ export class VacayMcp {
})
async declineVacayInvite({ planId }: { planId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
this.vacay.declineInvite(ctx.userId, planId, undefined);
await this.vacay.declineInvite(ctx.userId, planId, undefined);
return ok({ success: true });
}
@@ -235,8 +235,8 @@ export class VacayMcp {
})
async cancelVacayInvite({ targetUserId }: { targetUserId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.cancelInvite(planId, targetUserId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.cancelInvite(planId, targetUserId);
return ok({ success: true });
}
@@ -250,7 +250,7 @@ export class VacayMcp {
})
async dissolveVacayPlan(_args: Record<string, never>, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
this.vacay.dissolvePlan(ctx.userId, undefined);
await this.vacay.dissolvePlan(ctx.userId, undefined);
return ok({ success: true });
}
@@ -263,8 +263,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async listVacayYears(_args: Record<string, never>, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const years = this.vacay.listYears(planId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const years = await this.vacay.listYears(planId);
return ok({ years });
}
@@ -280,8 +280,8 @@ export class VacayMcp {
})
async addVacayYear({ year }: { year: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const years = this.vacay.addYear(planId, year, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const years = await this.vacay.addYear(planId, year, undefined);
return ok({ years });
}
@@ -297,8 +297,8 @@ export class VacayMcp {
})
async deleteVacayYear({ year }: { year: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const years = this.vacay.deleteYear(planId, year, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const years = await this.vacay.deleteYear(planId, year, undefined);
return ok({ years });
}
@@ -313,8 +313,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getVacayEntries({ year }: { year: number }, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const entries = this.vacay.getEntries(planId, String(year), ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const entries = await this.vacay.getEntries(planId, String(year), ctx.userId);
return ok({ entries });
}
@@ -336,17 +336,17 @@ export class VacayMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
let userId = ctx.userId;
if (targetUserId !== undefined && targetUserId !== ctx.userId) {
// Same gate as POST /entries/toggle: booking for someone else reaches only
// the members of the caller's own plan, and nobody outside it.
if (!this.vacay.getPlanUsers(planId).find((u) => u.id === targetUserId)) {
if (!(await this.vacay.getPlanUsers(planId)).find((u) => u.id === targetUserId)) {
return errorResult('User not in plan');
}
userId = targetUserId;
}
const result = this.vacay.toggleEntry(userId, planId, date, fraction, kind, undefined);
const result = await this.vacay.toggleEntry(userId, planId, date, fraction, kind, undefined);
if (result.error) return errorResult(result.error);
return ok(result);
}
@@ -364,8 +364,8 @@ export class VacayMcp {
})
async toggleCompanyHoliday({ date, note }: { date: string; note?: string }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const result = this.vacay.toggleCompanyHoliday(planId, date, note, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const result = await this.vacay.toggleCompanyHoliday(planId, date, note, undefined);
return ok(result);
}
@@ -380,8 +380,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getVacayStats({ year }: { year: number }, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const stats = this.vacay.getStats(planId, year);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const stats = await this.vacay.getStats(planId, year);
return ok({ stats });
}
@@ -398,8 +398,8 @@ export class VacayMcp {
})
async updateVacayStats({ year, vacationDays }: { year: number; vacationDays: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.updateStats(ctx.userId, planId, year, vacationDays, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.updateStats(ctx.userId, planId, year, vacationDays, undefined);
return ok({ success: true });
}
@@ -422,10 +422,10 @@ export class VacayMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
// An omitted type stays undefined so the service default (and the default
// colour that goes with it) applies, exactly as on the REST route.
const calendar = this.vacay.addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined, type);
const calendar = await this.vacay.addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined, type);
return ok({ calendar });
}
@@ -446,8 +446,8 @@ export class VacayMcp {
ctx: McpContext,
) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
const cal = this.vacay.updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const cal = await this.vacay.updateHolidayCalendar(calendarId, planId, { label, color }, undefined);
if (!cal) return errorResult('Holiday calendar not found.');
return ok({ calendar: cal });
}
@@ -464,8 +464,8 @@ export class VacayMcp {
})
async deleteHolidayCalendar({ calendarId }: { calendarId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const planId = this.vacay.getActivePlanId(ctx.userId);
this.vacay.deleteHolidayCalendar(calendarId, planId, undefined);
const planId = await this.vacay.getActivePlanId(ctx.userId);
await this.vacay.deleteHolidayCalendar(calendarId, planId, undefined);
return ok({ success: true });
}
@@ -547,7 +547,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async listVacayShares(_args: Record<string, never>, ctx: McpContext) {
return ok(this.vacay.listShares(ctx.userId));
return ok(await this.vacay.listShares(ctx.userId));
}
@Tool({
@@ -559,7 +559,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getShareableVacayUsers(_args: Record<string, never>, ctx: McpContext) {
return ok({ users: this.vacay.getShareAvailableUsers(ctx.userId) });
return ok({ users: await this.vacay.getShareAvailableUsers(ctx.userId) });
}
@Tool({
@@ -576,7 +576,7 @@ export class VacayMcp {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
const me = await this.auth.getCurrentUser(ctx.userId);
if (!me) return errorResult('User not found.');
const result = this.vacay.shareCalendar(ctx.userId, me.email, targetUserId);
const result = await this.vacay.shareCalendar(ctx.userId, me.email, targetUserId);
if (result.error) return errorResult(result.error);
return ok({ success: true });
}
@@ -593,7 +593,7 @@ export class VacayMcp {
})
async unshareVacayCalendar({ shareId }: { shareId: number }, ctx: McpContext) {
if (await this.auth.isDemoUser(ctx.userId)) return demoDenied();
if (!this.vacay.removeShare(shareId, ctx.userId, undefined)) {
if (!(await this.vacay.removeShare(shareId, ctx.userId, undefined))) {
return errorResult('Share not found.');
}
return ok({ success: true });
@@ -610,7 +610,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async getSharedVacayCalendars({ year }: { year: number }, ctx: McpContext) {
return ok({ calendars: this.vacay.getSharedCalendars(ctx.userId, String(year)) });
return ok({ calendars: await this.vacay.getSharedCalendars(ctx.userId, String(year)) });
}
// -------------------------------------------------------------------------
@@ -626,7 +626,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async vacayPlanResource(uri: URL, ctx: McpContext) {
const plan = this.vacay.getPlanData(ctx.userId);
const plan = await this.vacay.getPlanData(ctx.userId);
return {
contents: [{
uri: uri.href,
@@ -645,8 +645,8 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async vacayEntriesResource(uri: URL, { year }: { year: string | string[] }, ctx: McpContext) {
const planId = this.vacay.getActivePlanId(ctx.userId);
const entries = this.vacay.getEntries(planId, Array.isArray(year) ? year[0] : year, ctx.userId);
const planId = await this.vacay.getActivePlanId(ctx.userId);
const entries = await this.vacay.getEntries(planId, Array.isArray(year) ? year[0] : year, ctx.userId);
return {
contents: [{
uri: uri.href,
@@ -665,7 +665,7 @@ export class VacayMcp {
access: { group: 'vacay', mode: 'read' },
})
async vacayHolidaysResource(uri: URL, { year }: { year: string | string[] }, ctx: McpContext) {
const plan = this.vacay.getActivePlan(ctx.userId);
const plan = await this.vacay.getActivePlan(ctx.userId);
const json = (data: unknown) => ({
contents: [{
uri: uri.href,
+2 -2
View File
@@ -31,7 +31,7 @@ export class VacayRpc {
const userId = this.requireVacayUser(ctx, 'writes');
const date = this.dateStr(params.date);
await this.requireVacayAddon();
return this.vacay.toggleEntry(userId, this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined);
return await this.vacay.toggleEntry(userId, await this.vacay.getActivePlanId(userId), date, 1, 'vacation', undefined);
}
@PluginMethod('vacay.toggleCompanyHoliday', { permission: 'db:write:vacay' })
@@ -40,7 +40,7 @@ export class VacayRpc {
const date = this.dateStr(params.date);
const note = typeof params.note === 'string' ? params.note.slice(0, 256) : undefined;
await this.requireVacayAddon();
return this.vacay.toggleCompanyHoliday(this.vacay.getActivePlanId(userId), date, note, undefined);
return await this.vacay.toggleCompanyHoliday(await this.vacay.getActivePlanId(userId), date, note, undefined);
}
private requireVacayUser(ctx: PluginRpcContext, kind: 'reads' | 'writes'): number {
+161 -138
View File
@@ -2,6 +2,7 @@ import { Injectable, BadRequestException } from '@nestjs/common';
import { manualSchoolRegionId } from '@trek/shared';
import { RealtimeService } from '../realtime/realtime.service';
import { DatabaseService } from '../database/database.service';
import { UnitOfWork } from '../database/unit-of-work';
import { NotificationsService } from '../notifications/notifications.service';
import { discardBody, readCappedJson } from '../../utils/cappedFetch';
@@ -187,6 +188,7 @@ export class VacayService {
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
private readonly notifications: NotificationsService,
private readonly uow: UnitOfWork,
) {}
private readonly holidayCache = new Map<string, { data: unknown; time: number }>();
@@ -200,12 +202,12 @@ export class VacayService {
* half day (#552) counts as 0.5 and a full day as 1. Entries predating the
* feature have fraction = 1, so this matches the old COUNT(*) for them.
*/
private usedDays(userId: number, planId: number, year: number): number {
private async usedDays(userId: number, planId: number, year: number): Promise<number> {
// Comp/Flex days (#1074) are free — kind='comp' contributes 0 to the entitlement,
// vacation days contribute their fraction. Entries predating the column are
// 'vacation' by default. The window (#737) is the user's leave-year period; for
// 'calendar' it is Jan 1 – Dec 31, byte-identical to the old date-prefix match.
const { start, end } = this.resolveYearWindow(userId, year);
const { start, end } = await this.resolveYearWindow(userId, year);
const row = this.db.get<{ used: number }>(
"SELECT COALESCE(SUM(CASE WHEN kind = 'comp' THEN 0 ELSE fraction END), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ?",
userId, planId, start, end
@@ -214,8 +216,8 @@ export class VacayService {
}
/** Comp/Flex days (#1074) used in a user's leave-year period — SUM of fractions for kind='comp'. */
private compUsedDays(userId: number, planId: number, year: number): number {
const { start, end } = this.resolveYearWindow(userId, year);
private async compUsedDays(userId: number, planId: number, year: number): Promise<number> {
const { start, end } = await this.resolveYearWindow(userId, year);
const row = this.db.get<{ used: number }>(
"SELECT COALESCE(SUM(fraction), 0) AS used FROM vacay_entries WHERE user_id = ? AND plan_id = ? AND date >= ? AND date < ? AND kind = 'comp'",
userId, planId, start, end
@@ -227,13 +229,13 @@ export class VacayService {
// Configurable vacation year (#737)
// -------------------------------------------------------------------------
getUserYearSettings(userId: number): VacayUserSettings | undefined {
async getUserYearSettings(userId: number): Promise<VacayUserSettings | undefined> {
return this.db.get<VacayUserSettings>('SELECT * FROM vacay_user_settings WHERE user_id = ?', userId);
}
/** A user's leave-year settings with the calendar defaults filled in (#737). */
getYearSettings(userId: number): VacayUserSettings {
return this.getUserYearSettings(userId) ?? { user_id: userId, year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null };
async getYearSettings(userId: number): Promise<VacayUserSettings> {
return (await this.getUserYearSettings(userId)) ?? { user_id: userId, year_type: 'calendar', year_start_month: 1, year_start_day: 1, hire_date: null };
}
/**
@@ -244,8 +246,8 @@ export class VacayService {
* end exclusive. Because periods are consecutive, `year-1` is always the window that
* ends where `year`'s begins, so the carry-over chains stay valid unchanged.
*/
resolveYearWindow(userId: number, year: number): { start: string; end: string } {
const s = this.getUserYearSettings(userId);
async resolveYearWindow(userId: number, year: number): Promise<{ start: string; end: string }> {
const s = await this.getUserYearSettings(userId);
// 'anniversary' before a hire date is entered has nothing to anchor to, so it
// reads as the calendar default rather than borrowing a month left behind by a
// previous 'fiscal' setting — the client mirror resolves it the same way.
@@ -272,13 +274,13 @@ export class VacayService {
* (#737). Without a viewer — MCP resources, which are plan-scoped — it stays the
* plain calendar year, which is exactly what a 'calendar' user resolves to.
*/
private viewerWindow(year: number | string, viewerId?: number): { start: string; end: string } {
private async viewerWindow(year: number | string, viewerId?: number): Promise<{ start: string; end: string }> {
const y = typeof year === 'number' ? year : Number.parseInt(year, 10);
// A non-numeric year matched nothing under the old date prefix; an empty range
// matches nothing either, so a bad parameter still yields an empty result.
if (!Number.isFinite(y)) return { start: '', end: '' };
if (viewerId == null) return { start: `${y}-01-01`, end: `${y + 1}-01-01` };
return this.resolveYearWindow(viewerId, y);
return await this.resolveYearWindow(viewerId, y);
}
/**
@@ -294,8 +296,8 @@ export class VacayService {
* only the grid's edges are approximate. Rendering a 13th month card would fix it
* and was deliberately not taken.
*/
private viewerGridWindow(year: number | string, viewerId?: number): { start: string; end: string } {
const w = this.viewerWindow(year, viewerId);
private async viewerGridWindow(year: number | string, viewerId?: number): Promise<{ start: string; end: string }> {
const w = await this.viewerWindow(year, viewerId);
if (!w.start) return w;
return { start: `${w.start.slice(0, 7)}-01`, end: `${w.end.slice(0, 7)}-01` };
}
@@ -305,17 +307,17 @@ export class VacayService {
* window that starts later in the year, today still belongs to the period named
* after the previous calendar year — 'calendar' users always get today's year.
*/
currentPeriodYear(userId: number, date = new Date()): number {
async currentPeriodYear(userId: number, date = new Date()): Promise<number> {
const y = date.getFullYear();
const iso = `${y}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
return iso < this.resolveYearWindow(userId, y).start ? y - 1 : y;
return iso < (await this.resolveYearWindow(userId, y)).start ? y - 1 : y;
}
/** Upsert a user's leave-year settings (#737). */
updateYearSettings(
async updateYearSettings(
userId: number,
data: { year_type?: unknown; year_start_month?: unknown; year_start_day?: unknown; hire_date?: unknown },
): VacayUserSettings {
): Promise<VacayUserSettings> {
const type = normalizeYearType(data.year_type);
const month = Math.min(12, Math.max(1, Number.parseInt(String(data.year_start_month ?? 1), 10) || 1));
const day = Math.min(31, Math.max(1, Number.parseInt(String(data.year_start_day ?? 1), 10) || 1));
@@ -326,21 +328,21 @@ export class VacayService {
ON CONFLICT(user_id) DO UPDATE SET year_type = excluded.year_type, year_start_month = excluded.year_start_month,
year_start_day = excluded.year_start_day, hire_date = excluded.hire_date
`, userId, type, month, day, hire);
return this.getUserYearSettings(userId)!;
return (await this.getUserYearSettings(userId))!;
}
// -------------------------------------------------------------------------
// Plan management
// -------------------------------------------------------------------------
getOwnPlan(userId: number): VacayPlan {
async getOwnPlan(userId: number): Promise<VacayPlan> {
let plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE owner_id = ?', userId);
if (!plan) {
this.db.run('INSERT INTO vacay_plans (owner_id) VALUES (?)', userId);
plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE owner_id = ?', userId)!;
// Seed the period today falls into — with a shifted leave year (#737) that is
// not necessarily the current calendar year.
const yr = this.currentPeriodYear(userId);
const yr = await this.currentPeriodYear(userId);
this.db.run('INSERT OR IGNORE INTO vacay_years (plan_id, year) VALUES (?, ?)', plan.id, yr);
this.db.run('INSERT OR IGNORE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)', userId, plan.id, yr);
this.db.run('INSERT OR IGNORE INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)', userId, plan.id, '#6366f1');
@@ -348,26 +350,26 @@ export class VacayService {
return plan;
}
getActivePlan(userId: number): VacayPlan {
async getActivePlan(userId: number): Promise<VacayPlan> {
const membership = this.db.get<{ plan_id: number }>(`
SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'
`, userId);
if (membership) {
return this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', membership.plan_id)!;
}
return this.getOwnPlan(userId);
return await this.getOwnPlan(userId);
}
getActivePlanId(userId: number): number {
return this.getActivePlan(userId).id;
async getActivePlanId(userId: number): Promise<number> {
return (await this.getActivePlan(userId)).id;
}
shiftOwnerEntriesForTripWindow(
async shiftOwnerEntriesForTripWindow(
ownerId: number,
oldStart: string,
oldEnd: string,
newStart: string
): void {
): Promise<void> {
const row = this.db.get<{ days: number }>(
'SELECT CAST(julianday(?) - julianday(?) AS INTEGER) AS days',
newStart, oldStart
@@ -375,7 +377,7 @@ export class VacayService {
const offset = row?.days ?? 0;
if (offset === 0) return;
const plan = this.getOwnPlan(ownerId);
const plan = await this.getOwnPlan(ownerId);
this.db.run(
`UPDATE OR IGNORE vacay_entries
@@ -387,7 +389,7 @@ export class VacayService {
);
}
getPlanUsers(planId: number): VacayUser[] {
async getPlanUsers(planId: number): Promise<VacayUser[]> {
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
if (!plan) return [];
const owner = this.db.get<VacayUser>('SELECT id, username, email FROM users WHERE id = ?', plan.owner_id)!;
@@ -403,11 +405,11 @@ export class VacayService {
// WebSocket notifications
// -------------------------------------------------------------------------
notifyPlanUsers(
async notifyPlanUsers(
planId: number,
excludeSid: string | undefined,
event: 'vacay:update' | 'vacay:settings' | 'vacay:accepted' | 'vacay:declined' = 'vacay:update',
): void {
): Promise<void> {
try {
const plan = this.db.get<{ owner_id: number }>('SELECT owner_id FROM vacay_plans WHERE id = ?', planId);
if (!plan) return;
@@ -420,12 +422,12 @@ export class VacayService {
// union proves invite/cancelled never reach this method — their senders
// call broadcastToUser directly — so only declined needs excluding here.)
if (event !== 'vacay:declined') {
this.notifyShareViewers(userIds, excludeSid);
await this.notifyShareViewers(userIds, excludeSid);
}
} catch { /* websocket not available */ }
}
notifyShareViewers(ownerIds: number[], excludeSid?: string): void {
async notifyShareViewers(ownerIds: number[], excludeSid?: string): Promise<void> {
if (ownerIds.length === 0) return;
try {
const rows = this.db.all<{ user_id: number }>(
@@ -449,11 +451,11 @@ export class VacayService {
// A shifted leave year (#737) runs into the next calendar year, so collect the
// calendar years the members' windows actually touch — not just the period ids.
// With everyone on 'calendar' this is the same set as before.
const members = this.getPlanUsers(planId);
const members = await this.getPlanUsers(planId);
const calendarYears = new Set<number>();
for (const { year } of years) {
calendarYears.add(year);
for (const m of members) calendarYears.add(windowEndYear(this.resolveYearWindow(m.id, year).end));
for (const m of members) calendarYears.add(windowEndYear((await this.resolveYearWindow(m.id, year)).end));
}
for (const cal of calendars) {
const country = cal.region.split('-')[0];
@@ -537,14 +539,14 @@ export class VacayService {
if (carry_over_enabled === true) {
// The chained per-year/per-user recompute is atomic — a failure mid-chain
// would otherwise leave later years carrying stale balances.
this.db.transaction(() => {
await this.uow.transactional(async () => {
const years = this.db.all<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year', planId);
const users = this.getPlanUsers(planId);
const users = await this.getPlanUsers(planId);
for (let i = 0; i < years.length - 1; i++) {
const yr = years[i].year;
const nextYr = years[i + 1].year;
for (const u of users) {
const used = this.usedDays(u.id, planId, yr);
const used = await this.usedDays(u.id, planId, yr);
const config = this.db.get<VacayUserYear>('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, yr);
const total = (config ? config.vacation_days : 30) + (config ? config.carried_over : 0);
const carry = Math.max(0, total - used);
@@ -557,7 +559,7 @@ export class VacayService {
});
}
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
const updated = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId)!;
const updatedCalendars = this.db.all<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ? ORDER BY sort_order, id', planId);
@@ -578,26 +580,26 @@ export class VacayService {
// Holiday calendars CRUD
// -------------------------------------------------------------------------
addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') {
this.validateManualRegion(region, type);
async addHolidayCalendar(planId: number, region: string, label: string | null, color: string | undefined, sortOrder: number | undefined, socketId: string | undefined, type: 'public_holiday' | 'school_holiday' = 'public_holiday') {
await this.validateManualRegion(region, type);
const result = this.db.run(
'INSERT INTO vacay_holiday_calendars (plan_id, type, region, label, color, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
planId, type, region, label || null, color || (type === 'school_holiday' ? '#a5f3fc' : '#fecaca'), sortOrder ?? 0
);
const cal = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ?', result.lastInsertRowid)!;
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return cal;
}
updateHolidayCalendar(
async updateHolidayCalendar(
calId: number,
planId: number,
body: { region?: string; label?: string | null; color?: string; sort_order?: number; type?: 'public_holiday' | 'school_holiday' },
socketId: string | undefined,
): VacayHolidayCalendar | null {
): Promise<VacayHolidayCalendar | null> {
const cal = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId);
if (!cal) return null;
this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type);
await this.validateManualRegion(body.region ?? cal.region, body.type ?? cal.type);
const { region, label, color, sort_order, type } = body;
const updates: string[] = [];
const params: (string | number | null)[] = [];
@@ -611,19 +613,19 @@ export class VacayService {
this.db.run(`UPDATE vacay_holiday_calendars SET ${updates.join(', ')} WHERE id = ?`, ...params);
}
const updated = this.db.get<VacayHolidayCalendar>('SELECT * FROM vacay_holiday_calendars WHERE id = ?', calId)!;
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return updated;
}
deleteHolidayCalendar(calId: number, planId: number, socketId: string | undefined): boolean {
async deleteHolidayCalendar(calId: number, planId: number, socketId: string | undefined): Promise<boolean> {
const cal = this.db.get('SELECT * FROM vacay_holiday_calendars WHERE id = ? AND plan_id = ?', calId, planId);
if (!cal) return false;
this.db.run('DELETE FROM vacay_holiday_calendars WHERE id = ?', calId);
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return true;
}
private validateManualRegion(code: string, type: string) {
private async validateManualRegion(code: string, type: string) {
if (!code.includes('-MANUAL-')) return;
const id = manualSchoolRegionId(code);
if (type !== 'school_holiday' || !id || !this.db.get('SELECT id FROM school_holiday_regions WHERE id = ? AND country = ?', id, code.slice(0, 2))) {
@@ -635,19 +637,19 @@ export class VacayService {
// User colors
// -------------------------------------------------------------------------
setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): void {
async setUserColor(userId: number, planId: number, color: string | undefined, socketId: string | undefined): Promise<void> {
this.db.run(`
INSERT INTO vacay_user_colors (user_id, plan_id, color) VALUES (?, ?, ?)
ON CONFLICT(user_id, plan_id) DO UPDATE SET color = excluded.color
`, userId, planId, color || '#6366f1');
this.notifyPlanUsers(planId, socketId, 'vacay:update');
await this.notifyPlanUsers(planId, socketId, 'vacay:update');
}
// -------------------------------------------------------------------------
// Invitations
// -------------------------------------------------------------------------
sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): { error?: string; status?: number } {
async sendInvite(planId: number, inviterId: number, inviterUsername: string, inviterEmail: string, targetUserId: number): Promise<{ error?: string; status?: number }> {
if (targetUserId === inviterId) return { error: 'Cannot invite yourself', status: 400 };
// The picker no longer offers guests, but the id arrives from the client, so the
@@ -684,10 +686,10 @@ export class VacayService {
return {};
}
acceptInvite(userId: number, planId: number, socketId: string | undefined): { error?: string; status?: number } {
async acceptInvite(userId: number, planId: number, socketId: string | undefined): Promise<{ error?: string; status?: number }> {
// The accept flow is a multi-statement write (status flip + entry/year/color
// migration + seeding) — atomic, so a failure can't leave the member half-fused.
const result = this.db.transaction((): { error?: string; status?: number } => {
const result = await this.uow.transactional(async (): Promise<{ error?: string; status?: number }> => {
const invite = this.db.get<VacayPlanMember>("SELECT * FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, userId);
if (!invite) return { error: 'No pending invite', status: 404 };
@@ -731,16 +733,16 @@ export class VacayService {
// Only announce a fusion that actually happened — the transaction returns the
// refusal for an invite that was already gone.
if (!result.error) this.notifyPlanUsers(planId, socketId, 'vacay:accepted');
if (!result.error) await this.notifyPlanUsers(planId, socketId, 'vacay:accepted');
return result;
}
declineInvite(userId: number, planId: number, socketId: string | undefined): void {
async declineInvite(userId: number, planId: number, socketId: string | undefined): Promise<void> {
this.db.run("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, userId);
this.notifyPlanUsers(planId, socketId, 'vacay:declined');
await this.notifyPlanUsers(planId, socketId, 'vacay:declined');
}
cancelInvite(planId: number, targetUserId: number): void {
async cancelInvite(planId: number, targetUserId: number): Promise<void> {
this.db.run("DELETE FROM vacay_plan_members WHERE plan_id = ? AND user_id = ? AND status = 'pending'", planId, targetUserId);
try {
@@ -752,20 +754,20 @@ export class VacayService {
// Plan dissolution
// -------------------------------------------------------------------------
dissolvePlan(userId: number, socketId: string | undefined): void {
async dissolvePlan(userId: number, socketId: string | undefined): Promise<void> {
// Dissolution moves every member's entries back to their own plan and copies
// the company holidays — atomic, so a failure can't strand entries between plans.
const allUserIds = this.db.transaction(() => {
const plan = this.getActivePlan(userId);
const allUserIds = await this.uow.transactional(async () => {
const plan = await this.getActivePlan(userId);
const isOwnerFlag = plan.owner_id === userId;
const userIds = this.getPlanUsers(plan.id).map(u => u.id);
const userIds = (await this.getPlanUsers(plan.id)).map(u => u.id);
const companyHolidays = this.db.all<{ date: string; note: string }>('SELECT date, note FROM vacay_company_holidays WHERE plan_id = ?', plan.id);
if (isOwnerFlag) {
const members = this.db.all<{ user_id: number }>("SELECT user_id FROM vacay_plan_members WHERE plan_id = ? AND status = 'accepted'", plan.id);
for (const m of members) {
const memberPlan = this.getOwnPlan(m.user_id);
const memberPlan = await this.getOwnPlan(m.user_id);
this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', memberPlan.id, plan.id, m.user_id);
for (const ch of companyHolidays) {
this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', memberPlan.id, ch.date, ch.note);
@@ -773,7 +775,7 @@ export class VacayService {
}
this.db.run('DELETE FROM vacay_plan_members WHERE plan_id = ?', plan.id);
} else {
const ownPlan = this.getOwnPlan(userId);
const ownPlan = await this.getOwnPlan(userId);
this.db.run('UPDATE vacay_entries SET plan_id = ? WHERE plan_id = ? AND user_id = ?', ownPlan.id, plan.id, userId);
for (const ch of companyHolidays) {
this.db.run('INSERT OR IGNORE INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', ownPlan.id, ch.date, ch.note);
@@ -787,14 +789,14 @@ export class VacayService {
allUserIds.filter(id => id !== userId).forEach(id => this.realtime.broadcastToUser(id, { type: 'vacay:dissolved' }));
} catch { /* */ }
// Everyone's entries just moved back to their own plans — refresh read-only viewers.
this.notifyShareViewers(allUserIds, socketId);
await this.notifyShareViewers(allUserIds, socketId);
}
// -------------------------------------------------------------------------
// Available users
// -------------------------------------------------------------------------
getAvailableUsers(userId: number, planId: number) {
async getAvailableUsers(userId: number, planId: number) {
return this.db.all(`
SELECT u.id, u.username, u.email FROM users u
WHERE u.id != ?
@@ -818,7 +820,7 @@ export class VacayService {
// the owner's entries in whatever plan the owner is currently active in.
/** Like getActivePlan, but never lazily creates a plan for the user. */
private peekActivePlan(userId: number): VacayPlan | undefined {
private async peekActivePlan(userId: number): Promise<VacayPlan | undefined> {
const membership = this.db.get<{ plan_id: number }>(`
SELECT plan_id FROM vacay_plan_members WHERE user_id = ? AND status = 'accepted'
`, userId);
@@ -829,8 +831,8 @@ export class VacayService {
}
/** Colors already taken in the viewer's own calendar (their plan's members). */
private viewerColors(viewerId: number): Set<string> {
const plan = this.peekActivePlan(viewerId);
private async viewerColors(viewerId: number): Promise<Set<string>> {
const plan = await this.peekActivePlan(viewerId);
if (!plan) return new Set(['#6366f1']);
const rows = this.db.all<{ color: string }>('SELECT color FROM vacay_user_colors WHERE plan_id = ?', plan.id);
return new Set(rows.length > 0 ? rows.map(r => r.color) : ['#6366f1']);
@@ -842,8 +844,8 @@ export class VacayService {
* members or an earlier share — otherwise two people on the default indigo
* would be indistinguishable in the overlay.
*/
private shareDisplayColor(ownerId: number, usedColors: Set<string>): string {
const plan = this.peekActivePlan(ownerId);
private async shareDisplayColor(ownerId: number, usedColors: Set<string>): Promise<string> {
const plan = await this.peekActivePlan(ownerId);
const row = plan
? this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', ownerId, plan.id)
: undefined;
@@ -857,12 +859,12 @@ export class VacayService {
}
/** Users the viewer already sees in full via their active plan (owner + members). */
private viewerCoMemberIds(viewerId: number): Set<number> {
const plan = this.peekActivePlan(viewerId);
return new Set(plan ? this.getPlanUsers(plan.id).map(u => u.id) : []);
private async viewerCoMemberIds(viewerId: number): Promise<Set<number>> {
const plan = await this.peekActivePlan(viewerId);
return new Set(plan ? (await this.getPlanUsers(plan.id)).map(u => u.id) : []);
}
listShares(userId: number) {
async listShares(userId: number) {
// Usernames only, like the share picker — emails stay out of the share surface.
const outgoing = this.db.all<{ id: number; user_id: number; username: string }>(`
SELECT s.id, s.user_id, u.username
@@ -876,19 +878,25 @@ export class VacayService {
`, userId);
// Shares from someone the viewer is meanwhile fused with lie dormant — the
// plan already shows that calendar in full. They resume after dissolution.
const coMembers = this.viewerCoMemberIds(userId);
const usedColors = this.viewerColors(userId);
const incoming = incomingRows.filter(s => !coMembers.has(s.owner_id)).map(s => ({
id: s.id,
owner_id: s.owner_id,
username: s.username,
color: this.shareDisplayColor(s.owner_id, usedColors),
hidden: !!s.hidden,
}));
const coMembers = await this.viewerCoMemberIds(userId);
const usedColors = await this.viewerColors(userId);
// `map` cannot await, and `shareDisplayColor` mutates `usedColors` as it
// hands colors out, so the projection runs as an explicit loop — same rows,
// same order, same color-allocation sequence.
const incoming = [];
for (const s of incomingRows.filter(s => !coMembers.has(s.owner_id))) {
incoming.push({
id: s.id,
owner_id: s.owner_id,
username: s.username,
color: await this.shareDisplayColor(s.owner_id, usedColors),
hidden: !!s.hidden,
});
}
return { outgoing, incoming };
}
shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): { error?: string; status?: number } {
async shareCalendar(ownerId: number, ownerEmail: string, targetUserId: number, socketId?: string): Promise<{ error?: string; status?: number }> {
if (targetUserId === ownerId) return { error: 'Cannot share with yourself', status: 400 };
const targetUser = this.db.get('SELECT id FROM users WHERE id = ? AND COALESCE(is_guest, 0) = 0', targetUserId);
@@ -898,7 +906,7 @@ export class VacayService {
if (existing) return { error: 'Already shared', status: 400 };
// Plan members already see the whole calendar — sharing with them is moot.
if (this.getPlanUsers(this.getActivePlanId(ownerId)).find(u => u.id === targetUserId)) {
if ((await this.getPlanUsers(await this.getActivePlanId(ownerId))).find(u => u.id === targetUserId)) {
return { error: 'User is already in your calendar', status: 400 };
}
@@ -915,7 +923,7 @@ export class VacayService {
return {};
}
removeShare(shareId: number, userId: number, socketId?: string): boolean {
async removeShare(shareId: number, userId: number, socketId?: string): Promise<boolean> {
const share = this.db.get<VacayShare>('SELECT * FROM vacay_shares WHERE id = ?', shareId);
// The owner revokes, the viewer removes — both may delete, nobody else.
if (!share || (share.owner_id !== userId && share.user_id !== userId)) return false;
@@ -927,7 +935,7 @@ export class VacayService {
return true;
}
setShareHidden(shareId: number, userId: number, hidden: boolean, socketId?: string): boolean {
async setShareHidden(shareId: number, userId: number, hidden: boolean, socketId?: string): Promise<boolean> {
const share = this.db.get<VacayShare>('SELECT * FROM vacay_shares WHERE id = ?', shareId);
if (!share || share.user_id !== userId) return false;
this.db.run('UPDATE vacay_shares SET hidden = ? WHERE id = ?', hidden ? 1 : 0, shareId);
@@ -938,8 +946,8 @@ export class VacayService {
return true;
}
getShareAvailableUsers(userId: number) {
const planId = this.getActivePlanId(userId);
async getShareAvailableUsers(userId: number) {
const planId = await this.getActivePlanId(userId);
// Username only — unlike the fusion picker this lists users from other plans
// too, so exposing their emails here would widen the instance directory.
return this.db.all(`
@@ -956,10 +964,10 @@ export class VacayService {
`, userId, userId, planId, planId);
}
getSharedCalendars(viewerId: number, year: string) {
async getSharedCalendars(viewerId: number, year: string) {
// Shared calendars are drawn into the viewer's grid, so they load over the
// viewer's range (#737) even when the owner's leave year is shaped differently.
const { start, end } = this.viewerGridWindow(year, viewerId);
const { start, end } = await this.viewerGridWindow(year, viewerId);
const shares = this.db.all<{ id: number; owner_id: number; hidden: number; username: string }>(`
SELECT s.id, s.owner_id, s.hidden, u.username
FROM vacay_shares s JOIN users u ON s.owner_id = u.id
@@ -967,13 +975,18 @@ export class VacayService {
`, viewerId);
// Same dormancy rule as listShares: fused co-members are already fully visible.
const coMembers = this.viewerCoMemberIds(viewerId);
const usedColors = this.viewerColors(viewerId);
return shares.filter(s => !coMembers.has(s.owner_id)).map(s => {
const color = this.shareDisplayColor(s.owner_id, usedColors);
const plan = this.peekActivePlan(s.owner_id);
const coMembers = await this.viewerCoMemberIds(viewerId);
const usedColors = await this.viewerColors(viewerId);
// `map` cannot await, and `shareDisplayColor` mutates `usedColors` as it
// hands colors out, so the projection runs as an explicit loop — same rows,
// same order, same color-allocation sequence.
const calendars = [];
for (const s of shares.filter(s => !coMembers.has(s.owner_id))) {
const color = await this.shareDisplayColor(s.owner_id, usedColors);
const plan = await this.peekActivePlan(s.owner_id);
if (!plan) {
return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries: [], companyHolidays: [] };
calendars.push({ share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries: [], companyHolidays: [] });
continue;
}
const entries = this.db.all(
'SELECT date, fraction, kind FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ? ORDER BY date',
@@ -985,36 +998,37 @@ export class VacayService {
const companyHolidays = plan.company_holidays_enabled
? this.db.all('SELECT date FROM vacay_company_holidays WHERE plan_id = ? AND date >= ? AND date < ? ORDER BY date', plan.id, start, end)
: [];
return { share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries, companyHolidays };
});
calendars.push({ share_id: s.id, owner_id: s.owner_id, owner_name: s.username, color, hidden: !!s.hidden, entries, companyHolidays });
}
return calendars;
}
// -------------------------------------------------------------------------
// Years
// -------------------------------------------------------------------------
listYears(planId: number): number[] {
async listYears(planId: number): Promise<number[]> {
const rows = this.db.all<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? ORDER BY year', planId);
return rows.map(y => y.year);
}
addYear(planId: number, year: number, socketId: string | undefined): number[] {
async addYear(planId: number, year: number, socketId: string | undefined): Promise<number[]> {
// A duplicate year is a no-op (the legacy blanket try/catch was written for
// exactly this constraint hit); real errors now propagate instead of being
// swallowed. The insert + per-user seeding runs atomically.
const exists = this.db.get('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year);
if (!exists) {
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)', planId, year);
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = this.getPlanUsers(planId);
const users = await this.getPlanUsers(planId);
for (const u of users) {
let carriedOver = 0;
if (carryOverEnabled) {
const prevConfig = this.db.get<VacayUserYear>('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, year - 1);
if (prevConfig) {
const used = this.usedDays(u.id, planId, year - 1);
const used = await this.usedDays(u.id, planId, year - 1);
const total = prevConfig.vacation_days + prevConfig.carried_over;
carriedOver = Math.max(0, total - used);
}
@@ -1023,21 +1037,21 @@ export class VacayService {
}
});
}
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return this.listYears(planId);
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return await this.listYears(planId);
}
deleteYear(planId: number, year: number, socketId: string | undefined): number[] {
async deleteYear(planId: number, year: number, socketId: string | undefined): Promise<number[]> {
// Year removal deletes across four tables and recomputes the next year's
// carry-over — atomic, so a failure can't leave entries without their year.
this.db.transaction(() => {
await this.uow.transactional(async () => {
this.db.run('DELETE FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year);
// Members can be on differently shaped leave years (#737), so entries go per
// author over that author's period rather than by one shared year prefix.
// Authors are read off the entries themselves so orphans are cleared too.
const authors = this.db.all<{ user_id: number }>('SELECT DISTINCT user_id FROM vacay_entries WHERE plan_id = ?', planId);
for (const { user_id } of authors) {
const { start, end } = this.resolveYearWindow(user_id, year);
const { start, end } = await this.resolveYearWindow(user_id, year);
this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date >= ? AND date < ?', planId, user_id, start, end);
}
// Company holidays belong to the plan, not to a member, and every member sees
@@ -1045,8 +1059,13 @@ export class VacayService {
// range is therefore the intersection of all member windows — anything outside
// it still sits inside a period somebody else has not deleted.
const owner = this.db.get<{ owner_id: number }>('SELECT owner_id FROM vacay_plans WHERE id = ?', planId);
const members = this.getPlanUsers(planId);
const windows = (members.length > 0 ? members.map(m => m.id) : [owner?.owner_id ?? -1]).map(id => this.resolveYearWindow(id, year));
const members = await this.getPlanUsers(planId);
// `map` cannot await the per-member window read, so it runs as an explicit
// loop — same ids, same order.
const windows = [];
for (const id of members.length > 0 ? members.map(m => m.id) : [owner?.owner_id ?? -1]) {
windows.push(await this.resolveYearWindow(id, year));
}
const holidayStart = windows.reduce((a, w) => (w.start > a ? w.start : a), windows[0].start);
const holidayEnd = windows.reduce((a, w) => (w.end < a ? w.end : a), windows[0].end);
if (holidayStart < holidayEnd) {
@@ -1059,7 +1078,7 @@ export class VacayService {
if (nextYearExists) {
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = this.getPlanUsers(planId);
const users = await this.getPlanUsers(planId);
const prevYear = this.db.get<{ year: number }>('SELECT year FROM vacay_years WHERE plan_id = ? AND year < ? ORDER BY year DESC LIMIT 1', planId, year + 1);
for (const u of users) {
@@ -1067,7 +1086,7 @@ export class VacayService {
if (carryOverEnabled && prevYear) {
const prevConfig = this.db.get<VacayUserYear>('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, prevYear.year);
if (prevConfig) {
const used = this.usedDays(u.id, planId, prevYear.year);
const used = await this.usedDays(u.id, planId, prevYear.year);
const total = prevConfig.vacation_days + prevConfig.carried_over;
carry = Math.max(0, total - used);
}
@@ -1077,8 +1096,8 @@ export class VacayService {
}
});
this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return this.listYears(planId);
await this.notifyPlanUsers(planId, socketId, 'vacay:settings');
return await this.listYears(planId);
}
// -------------------------------------------------------------------------
@@ -1090,8 +1109,8 @@ export class VacayService {
* window (#737) — a shifted year spans two calendar years, so the old year prefix
* would drop the second half. For 'calendar' the range is Jan 1 – Dec 31 again.
*/
getEntries(planId: number, year: string, viewerId?: number) {
const { start, end } = this.viewerGridWindow(year, viewerId);
async getEntries(planId: number, year: string, viewerId?: number) {
const { start, end } = await this.viewerGridWindow(year, viewerId);
const entries = this.db.all(`
SELECT e.*, u.username as person_name, COALESCE(c.color, '#6366f1') as person_color
FROM vacay_entries e
@@ -1103,7 +1122,7 @@ export class VacayService {
return { entries, companyHolidays };
}
toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): { action?: string; fraction?: number; kind?: string; error?: string } {
async toggleEntry(userId: number, planId: number, date: string, fraction?: unknown, kind?: unknown, socketId?: string): Promise<{ action?: string; fraction?: number; kind?: string; error?: string }> {
const frac = normalizeFraction(fraction);
const knd = normalizeKind(kind);
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
@@ -1114,32 +1133,32 @@ export class VacayService {
// clicking a different type or fraction converts it in place (#552/#1074).
if (existing.fraction === frac && (existing.kind || 'vacation') === knd) {
this.db.run('DELETE FROM vacay_entries WHERE id = ?', existing.id);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
return { action: 'removed' };
}
// Removing a stray entry on a blocked day stays possible; keeping one
// there (converted in place) does not.
if (weekendBlocked) return { error: 'weekend_blocked' };
this.db.run('UPDATE vacay_entries SET fraction = ?, kind = ? WHERE id = ?', frac, knd, existing.id);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
return { action: 'updated', fraction: frac, kind: knd };
}
if (weekendBlocked) return { error: 'weekend_blocked' };
this.db.run('INSERT INTO vacay_entries (plan_id, user_id, date, note, fraction, kind) VALUES (?, ?, ?, ?, ?, ?)', planId, userId, date, '', frac, knd);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
return { action: 'added', fraction: frac, kind: knd };
}
toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): { action: string } {
async toggleCompanyHoliday(planId: number, date: string, note: string | undefined, socketId: string | undefined): Promise<{ action: string }> {
const existing = this.db.get<{ id: number }>('SELECT id FROM vacay_company_holidays WHERE plan_id = ? AND date = ?', planId, date);
if (existing) {
this.db.run('DELETE FROM vacay_company_holidays WHERE id = ?', existing.id);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
return { action: 'removed' };
} else {
this.db.run('INSERT INTO vacay_company_holidays (plan_id, date, note) VALUES (?, ?, ?)', planId, date, note || '');
this.db.run('DELETE FROM vacay_entries WHERE plan_id = ? AND date = ?', planId, date);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
return { action: 'added' };
}
}
@@ -1148,14 +1167,17 @@ export class VacayService {
// Stats
// -------------------------------------------------------------------------
getStats(planId: number, year: number) {
async getStats(planId: number, year: number) {
const plan = this.db.get<VacayPlan>('SELECT * FROM vacay_plans WHERE id = ?', planId);
const carryOverEnabled = plan ? !!plan.carry_over_enabled : true;
const users = this.getPlanUsers(planId);
const users = await this.getPlanUsers(planId);
return users.map(u => {
const used = this.usedDays(u.id, planId, year);
const compUsed = this.compUsedDays(u.id, planId, year);
// `map` cannot await, and the body also writes next year's carry-over, so
// the projection runs as an explicit loop — same users, same order, same writes.
const rows = [];
for (const u of users) {
const used = await this.usedDays(u.id, planId, year);
const compUsed = await this.compUsedDays(u.id, planId, year);
const config = this.db.get<VacayUserYear>('SELECT * FROM vacay_user_years WHERE user_id = ? AND plan_id = ? AND year = ?', u.id, planId, year);
const vacationDays = config ? config.vacation_days : 30;
const carriedOver = carryOverEnabled ? (config ? config.carried_over : 0) : 0;
@@ -1164,7 +1186,7 @@ export class VacayService {
const colorRow = this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', u.id, planId);
// The period this row was computed over (#737) — the UI labels the window and
// the carry-over source with it instead of assuming Jan–Dec / year − 1.
const window = this.resolveYearWindow(u.id, year);
const window = await this.resolveYearWindow(u.id, year);
const nextYearExists = this.db.get('SELECT id FROM vacay_years WHERE plan_id = ? AND year = ?', planId, year + 1);
if (nextYearExists && carryOverEnabled) {
@@ -1175,32 +1197,33 @@ export class VacayService {
`, u.id, planId, year + 1, carry, carry);
}
return {
rows.push({
user_id: u.id, person_name: u.username, person_color: colorRow?.color || '#6366f1',
year, vacation_days: vacationDays, carried_over: carriedOver,
total_available: total, used, remaining, comp_used: compUsed,
window_start: window.start, window_end: window.end,
};
});
});
}
return rows;
}
updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): void {
async updateStats(userId: number, planId: number, year: number, vacationDays: number, socketId: string | undefined): Promise<void> {
this.db.run(`
INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, ?, 0)
ON CONFLICT(user_id, plan_id, year) DO UPDATE SET vacation_days = excluded.vacation_days
`, userId, planId, year, vacationDays);
this.notifyPlanUsers(planId, socketId);
await this.notifyPlanUsers(planId, socketId);
}
// -------------------------------------------------------------------------
// GET /plan composite
// -------------------------------------------------------------------------
getPlanData(userId: number) {
const plan = this.getActivePlan(userId);
async getPlanData(userId: number) {
const plan = await this.getActivePlan(userId);
const activePlanId = plan.id;
const users = this.getPlanUsers(activePlanId).map(u => {
const users = (await this.getPlanUsers(activePlanId)).map(u => {
const colorRow = this.db.get<{ color: string }>('SELECT color FROM vacay_user_colors WHERE user_id = ? AND plan_id = ?', u.id, activePlanId);
return { ...u, color: colorRow?.color || '#6366f1' };
});