···173173 return;
174174 }
175175 }
176176- } catch { /* allow connection if DB check fails — fail open for relay */ }
176176+ } catch {
177177+ ws.close(4500, 'Internal error checking document access');
178178+ return;
179179+ }
177180178181 // Extract Tailscale identity from the upgrade request headers
179182 const wsUserLogin = request.headers['tailscale-user-login'] as string | undefined;
+6-2
server/routes/api-v1.ts
···1717 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL AND type = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
1818);
1919const countStmt = db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL');
2020+const countByTypeStmt = db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL AND type = ?');
2021const getOneStmt = db.prepare('SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE id = ?');
21222223// Search documents by name (encrypted names are matched client-side, but
···2627 const limit = Math.min(Math.max(parseInt(lim as string) || 50, 1), 200);
2728 const offset = Math.max(parseInt(off as string) || 0, 0);
28292929- const rows = (type && VALID_TYPES.includes(type as typeof VALID_TYPES[number]))
3030+ const isTypeFilter = type && VALID_TYPES.includes(type as typeof VALID_TYPES[number]);
3131+ const rows = isTypeFilter
3032 ? listByTypeStmt.all(type, limit, offset)
3133 : listAllStmt.all(limit, offset);
32343333- const total = (countStmt.get() as { count: number }).count;
3535+ const total = isTypeFilter
3636+ ? (countByTypeStmt.get(type) as { count: number }).count
3737+ : (countStmt.get() as { count: number }).count;
3438 res.json({ data: rows, total, limit, offset });
3539});
3640
···170170// Duration parsing (RFC 5545 §3.3.6)
171171// ---------------------------------------------------------------------------
172172173173-/** Parse an iCal DURATION like P1DT2H30M into minutes */
173173+/** Parse an iCal DURATION like P1DT2H30M into minutes.
174174+ * RFC 5545 §3.3.6: format is P[nW] or P[nY][nM][nD][T[nH][nM][nS]]
175175+ * M before T = months, M after T = minutes. */
174176function parseDuration(dur: string): number {
175177 let minutes = 0;
176176- const weekMatch = dur.match(/(\d+)W/);
177177- const dayMatch = dur.match(/(\d+)D/);
178178- const hourMatch = dur.match(/(\d+)H/);
179179- const minMatch = dur.match(/(\d+)M/);
178178+ const tIdx = dur.indexOf('T');
179179+ const datePart = tIdx >= 0 ? dur.slice(0, tIdx) : dur;
180180+ const timePart = tIdx >= 0 ? dur.slice(tIdx + 1) : '';
181181+182182+ const weekMatch = datePart.match(/(\d+)W/);
183183+ const monthMatch = datePart.match(/(\d+)M/);
184184+ const dayMatch = datePart.match(/(\d+)D/);
185185+ const hourMatch = timePart.match(/(\d+)H/);
186186+ const minMatch = timePart.match(/(\d+)M/);
180187181188 if (weekMatch) minutes += parseInt(weekMatch[1]!, 10) * 7 * 24 * 60;
189189+ if (monthMatch) minutes += parseInt(monthMatch[1]!, 10) * 30 * 24 * 60; // approximate month as 30 days
182190 if (dayMatch) minutes += parseInt(dayMatch[1]!, 10) * 24 * 60;
183191 if (hourMatch) minutes += parseInt(hourMatch[1]!, 10) * 60;
184192 if (minMatch) minutes += parseInt(minMatch[1]!, 10);
+47
tests/ics-export.test.ts
···127127});
128128129129// ---------------------------------------------------------------------------
130130+// Multi-day events
131131+// ---------------------------------------------------------------------------
132132+133133+describe('exportIcsFile multi-day events', () => {
134134+ it('exports an all-day multi-day event with DTEND as next day after endDate', () => {
135135+ const events = [makeEvent({
136136+ allDay: true,
137137+ date: '2026-04-01',
138138+ endDate: '2026-04-03',
139139+ startTime: '',
140140+ endTime: '',
141141+ })];
142142+ const ics = exportIcsFile(events, 'Cal');
143143+ const lines = icsLines(ics);
144144+ expect(lines).toContain('DTSTART;VALUE=DATE:20260401');
145145+ // RFC 5545: DTEND is exclusive, so endDate 2026-04-03 → DTEND 2026-04-04
146146+ expect(lines).toContain('DTEND;VALUE=DATE:20260404');
147147+ });
148148+149149+ it('exports a timed multi-day event with DTEND using endDate, not start date', () => {
150150+ const events = [makeEvent({
151151+ date: '2026-04-01',
152152+ endDate: '2026-04-03',
153153+ startTime: '09:00',
154154+ endTime: '17:00',
155155+ })];
156156+ const ics = exportIcsFile(events, 'Cal');
157157+ const lines = icsLines(ics);
158158+ expect(lines).toContain('DTSTART:20260401T090000');
159159+ // DTEND should use endDate (Apr 3), not start date (Apr 1)
160160+ expect(lines).toContain('DTEND:20260403T170000');
161161+ });
162162+163163+ it('falls back to start date for DTEND when endDate is absent', () => {
164164+ const events = [makeEvent({
165165+ date: '2026-04-15',
166166+ startTime: '09:00',
167167+ endTime: '10:00',
168168+ })];
169169+ const ics = exportIcsFile(events, 'Cal');
170170+ const lines = icsLines(ics);
171171+ expect(lines).toContain('DTSTART:20260415T090000');
172172+ expect(lines).toContain('DTEND:20260415T100000');
173173+ });
174174+});
175175+176176+// ---------------------------------------------------------------------------
130177// Description and escaping
131178// ---------------------------------------------------------------------------
132179
+20
tests/ics-parser.test.ts
···117117 it('handles minutes only', () => {
118118 expect(parseDuration('PT45M')).toBe(45);
119119 });
120120+121121+ it('treats M before T as months (~30 days), not minutes', () => {
122122+ // P1M = 1 month ≈ 30 days = 43200 minutes
123123+ expect(parseDuration('P1M')).toBe(30 * 24 * 60);
124124+ });
125125+126126+ it('treats M after T as minutes', () => {
127127+ // PT1M = 1 minute
128128+ expect(parseDuration('PT1M')).toBe(1);
129129+ });
130130+131131+ it('handles month M and minute M in the same duration', () => {
132132+ // P1MT30M = 1 month + 30 minutes = 43200 + 30 = 43230
133133+ expect(parseDuration('P1MT30M')).toBe(30 * 24 * 60 + 30);
134134+ });
135135+136136+ it('handles days + hours + minutes correctly (no month ambiguity)', () => {
137137+ // P1DT2H30M = 1 day + 2 hours + 30 minutes = 1440 + 120 + 30 = 1590
138138+ expect(parseDuration('P1DT2H30M')).toBe(1590);
139139+ });
120140});
121141122142// ---------------------------------------------------------------------------
+42
tests/recurring-events.test.ts
···9090 expect(result[0].date).toBe('2026-01-15');
9191 expect(result[5].date).toBe('2026-06-15');
9292 });
9393+9494+ it('does not overflow Jan 31 into March (pins to Feb 28)', () => {
9595+ // The primary fix: Jan 31 monthly should land on Feb 28, NOT Mar 3
9696+ const events = [makeEvent({
9797+ date: '2026-01-31',
9898+ recurrence: { type: 'monthly' },
9999+ })];
100100+ const result = expandRecurringEvents(events, '2026-01-01', '2026-03-31');
101101+ expect(result.length).toBeGreaterThanOrEqual(3);
102102+ expect(result[0].date).toBe('2026-01-31');
103103+ expect(result[1].date).toBe('2026-02-28'); // pinned, not Mar 03
104104+ // Recovers to 31 for months that support it
105105+ expect(result[2].date).toBe('2026-03-31');
106106+ });
107107+108108+ it('does not overflow Jan 31 into March in a leap year (pins to Feb 29)', () => {
109109+ // 2028 is a leap year
110110+ const events = [makeEvent({
111111+ date: '2028-01-31',
112112+ recurrence: { type: 'monthly' },
113113+ })];
114114+ const result = expandRecurringEvents(events, '2028-01-01', '2028-03-31');
115115+ expect(result.length).toBeGreaterThanOrEqual(3);
116116+ expect(result[0].date).toBe('2028-01-31');
117117+ expect(result[1].date).toBe('2028-02-29'); // leap year, pinned
118118+ // Recovers to 31 for March
119119+ expect(result[2].date).toBe('2028-03-31');
120120+ });
121121+122122+ it('does not overflow May 31 into July (pins to Jun 30)', () => {
123123+ // May 31 → Jun 30 (not Jul 01)
124124+ const events = [makeEvent({
125125+ date: '2026-05-31',
126126+ recurrence: { type: 'monthly' },
127127+ })];
128128+ const result = expandRecurringEvents(events, '2026-05-01', '2026-07-31');
129129+ expect(result.length).toBeGreaterThanOrEqual(3);
130130+ expect(result[0].date).toBe('2026-05-31');
131131+ expect(result[1].date).toBe('2026-06-30'); // pinned, not Jul 01
132132+ // Recovers to 31 for July
133133+ expect(result[2].date).toBe('2026-07-31');
134134+ });
93135 });
9413695137 describe('yearly recurrence', () => {
+25-2
tests/server/api-v1.test.ts
···4646 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE deleted_at IS NULL AND type = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?'
4747 );
4848 const countStmt = db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL');
4949+ const countByTypeStmt = db.prepare('SELECT COUNT(*) as count FROM documents WHERE deleted_at IS NULL AND type = ?');
4950 const getOneStmt = db.prepare('SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE id = ?');
50515152 const resolveStmtCache = new Map<number, ReturnType<typeof db.prepare>>();
···7778 const limit = Math.min(Math.max(parseInt(lim as string) || 50, 1), 200);
7879 const offset = Math.max(parseInt(off as string) || 0, 0);
79808080- const rows = (type && VALID_TYPES.includes(type as typeof VALID_TYPES[number]))
8181+ const isTypeFilter = type && VALID_TYPES.includes(type as typeof VALID_TYPES[number]);
8282+ const rows = isTypeFilter
8183 ? listByTypeStmt.all(type, limit, offset)
8284 : listAllStmt.all(limit, offset);
83858484- const total = (countStmt.get() as { count: number }).count;
8686+ const total = isTypeFilter
8787+ ? (countByTypeStmt.get(type) as { count: number }).count
8888+ : (countStmt.get() as { count: number }).count;
8589 res.json({ data: rows, total, limit, offset });
8690 });
8791···221225 const res = await fetch(`${baseUrl}/api/v1/documents`);
222226 const body = await res.json() as { data: Array<{ id: string }> };
223227 expect(body.data.some(d => d.id === deletedId)).toBe(false);
228228+ });
229229+230230+ it('returns type-filtered total count when type param is provided', async () => {
231231+ // Seed known quantities of each type
232232+ const calId1 = await seedDoc('calendar');
233233+ const calId2 = await seedDoc('calendar');
234234+ const docId = await seedDoc('doc');
235235+236236+ // Fetch only calendars
237237+ const calRes = await fetch(`${baseUrl}/api/v1/documents?type=calendar`);
238238+ const calBody = await calRes.json() as { data: Array<{ id: string; type: string }>; total: number };
239239+ // total should count only calendars, not all documents
240240+ expect(calBody.total).toBe(calBody.data.length);
241241+ expect(calBody.data.every(d => d.type === 'calendar')).toBe(true);
242242+243243+ // Fetch all documents — total should be larger
244244+ const allRes = await fetch(`${baseUrl}/api/v1/documents`);
245245+ const allBody = await allRes.json() as { total: number };
246246+ expect(allBody.total).toBeGreaterThan(calBody.total);
224247 });
225248});
226249