mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
Merge pull request #672 from mauriceboe/feature/uncategorized-filter
feat: add uncategorized filter to category dropdown and more
This commit is contained in:
@@ -45,6 +45,7 @@ import publicConfigRoutes from './routes/publicConfig';
|
||||
import { mcpHandler } from './mcp';
|
||||
import { Addon } from './types';
|
||||
import { getPhotoProviderConfig } from './services/memories/helpersService';
|
||||
import { getCollabFeatures } from './services/adminService';
|
||||
|
||||
export function createApp(): express.Application {
|
||||
const app = express();
|
||||
@@ -236,6 +237,7 @@ export function createApp(): express.Application {
|
||||
}
|
||||
|
||||
res.json({
|
||||
collabFeatures: getCollabFeatures(),
|
||||
addons: [
|
||||
...addons.map(a => ({ ...a, enabled: !!a.enabled })),
|
||||
...providers.map(p => ({
|
||||
|
||||
@@ -1605,6 +1605,16 @@ function runMigrations(db: Database.Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idx_idempotency_keys_created ON idempotency_keys(created_at);
|
||||
`);
|
||||
},
|
||||
|
||||
// Migration 101: Enable naver_list_import by default
|
||||
() => {
|
||||
db.prepare("UPDATE addons SET enabled = 1 WHERE id = 'naver_list_import'").run();
|
||||
},
|
||||
|
||||
// Migration 102: Add check_in_end column for check-in time ranges
|
||||
() => {
|
||||
try { db.exec('ALTER TABLE day_accommodations ADD COLUMN check_in_end TEXT'); } catch (err: any) { if (!err.message?.includes('duplicate column name')) throw err; }
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -334,6 +334,7 @@ function createTables(db: Database.Database): void {
|
||||
start_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
end_day_id INTEGER NOT NULL REFERENCES days(id) ON DELETE CASCADE,
|
||||
check_in TEXT,
|
||||
check_in_end TEXT,
|
||||
check_out TEXT,
|
||||
confirmation TEXT,
|
||||
notes TEXT,
|
||||
|
||||
@@ -92,7 +92,7 @@ function seedAddons(db: Database.Database): void {
|
||||
{ id: 'vacay', name: 'Vacay', description: 'Personal vacation day planner with calendar view', type: 'global', icon: 'CalendarDays', enabled: 1, sort_order: 10 },
|
||||
{ id: 'atlas', name: 'Atlas', description: 'World map of your visited countries with travel stats', type: 'global', icon: 'Globe', enabled: 1, sort_order: 11 },
|
||||
{ id: 'mcp', name: 'MCP', description: 'Model Context Protocol for AI assistant integration', type: 'integration', icon: 'Terminal', enabled: 0, sort_order: 12 },
|
||||
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 0, sort_order: 13 },
|
||||
{ id: 'naver_list_import', name: 'Naver List Import', description: 'Import places from shared Naver Maps lists', type: 'trip', icon: 'Link2', enabled: 1, sort_order: 13 },
|
||||
{ id: 'collab', name: 'Collab', description: 'Notes, polls, and live chat for trip collaboration', type: 'trip', icon: 'Users', enabled: 1, sort_order: 6 },
|
||||
{ id: 'journey', name: 'Journey', description: 'Trip tracking & travel journal — check-ins, photos, daily stories', type: 'global', icon: 'Compass', enabled: 0, sort_order: 35 },
|
||||
];
|
||||
|
||||
@@ -201,6 +201,24 @@ router.put('/bag-tracking', (req: Request, res: Response) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
router.get('/collab-features', (_req: Request, res: Response) => {
|
||||
res.json(svc.getCollabFeatures());
|
||||
});
|
||||
|
||||
router.put('/collab-features', (req: Request, res: Response) => {
|
||||
const result = svc.updateCollabFeatures(req.body);
|
||||
const authReq = req as AuthRequest;
|
||||
writeAudit({
|
||||
userId: authReq.user.id,
|
||||
action: 'admin.collab_features',
|
||||
ip: getClientIp(req),
|
||||
details: result,
|
||||
});
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// ── Packing Templates ──────────────────────────────────────────────────────
|
||||
|
||||
router.get('/packing-templates', (_req: Request, res: Response) => {
|
||||
|
||||
@@ -73,7 +73,7 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body;
|
||||
|
||||
if (!place_id || !start_day_id || !end_day_id) {
|
||||
return res.status(400).json({ error: 'place_id, start_day_id, and end_day_id are required' });
|
||||
@@ -82,7 +82,7 @@ accommodationsRouter.post('/', authenticate, requireTripAccess, (req: Request, r
|
||||
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
|
||||
|
||||
const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||
const accommodation = dayService.createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
res.status(201).json({ accommodation });
|
||||
broadcast(tripId, 'accommodation:created', { accommodation }, req.headers['x-socket-id'] as string);
|
||||
broadcast(tripId, 'reservation:created', {}, req.headers['x-socket-id'] as string);
|
||||
@@ -98,12 +98,12 @@ accommodationsRouter.put('/:id', authenticate, requireTripAccess, (req: Request,
|
||||
const existing = dayService.getAccommodation(id, tripId);
|
||||
if (!existing) return res.status(404).json({ error: 'Accommodation not found' });
|
||||
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = req.body;
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = req.body;
|
||||
|
||||
const errors = dayService.validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id);
|
||||
if (errors.length > 0) return res.status(404).json({ error: errors[0].message });
|
||||
|
||||
const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes });
|
||||
const accommodation = dayService.updateAccommodation(id, existing, { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes });
|
||||
res.json({ accommodation });
|
||||
broadcast(tripId, 'accommodation:updated', { accommodation }, req.headers['x-socket-id'] as string);
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ import { requireTripAccess } from '../middleware/tripAccess';
|
||||
import { broadcast } from '../websocket';
|
||||
import { validateStringLengths } from '../middleware/validate';
|
||||
import { checkPermission } from '../services/permissions';
|
||||
import { isAddonEnabled } from '../services/adminService';
|
||||
import { AuthRequest } from '../types';
|
||||
import {
|
||||
listPlaces,
|
||||
@@ -135,10 +134,6 @@ router.post('/import/naver-list', authenticate, requireTripAccess, async (req: R
|
||||
const authReq = req as AuthRequest;
|
||||
if (!checkPermission('place_edit', authReq.user.role, authReq.trip!.user_id, authReq.user.id, authReq.trip!.user_id !== authReq.user.id))
|
||||
return res.status(403).json({ error: 'No permission' });
|
||||
if (!isAddonEnabled('naver_list_import')) {
|
||||
return res.status(403).json({ error: 'Naver list import addon is disabled' });
|
||||
}
|
||||
|
||||
const { tripId } = req.params;
|
||||
const { url } = req.body;
|
||||
if (!url || typeof url !== 'string') return res.status(400).json({ error: 'URL is required' });
|
||||
|
||||
@@ -459,6 +459,31 @@ export function updateBagTracking(enabled: boolean) {
|
||||
return { enabled: !!enabled };
|
||||
}
|
||||
|
||||
// ── Collab Features ───────────────────────────────────────────────────────
|
||||
|
||||
const COLLAB_FEATURE_KEYS = ['collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled'] as const;
|
||||
|
||||
export function getCollabFeatures() {
|
||||
const rows = db.prepare("SELECT key, value FROM app_settings WHERE key IN ('collab_chat_enabled', 'collab_notes_enabled', 'collab_polls_enabled', 'collab_whatsnext_enabled')").all() as { key: string; value: string }[];
|
||||
const map: Record<string, string> = {};
|
||||
for (const r of rows) map[r.key] = r.value;
|
||||
return {
|
||||
chat: map['collab_chat_enabled'] !== 'false',
|
||||
notes: map['collab_notes_enabled'] !== 'false',
|
||||
polls: map['collab_polls_enabled'] !== 'false',
|
||||
whatsnext: map['collab_whatsnext_enabled'] !== 'false',
|
||||
};
|
||||
}
|
||||
|
||||
export function updateCollabFeatures(features: { chat?: boolean; notes?: boolean; polls?: boolean; whatsnext?: boolean }) {
|
||||
const mapping: Record<string, string> = { chat: 'collab_chat_enabled', notes: 'collab_notes_enabled', polls: 'collab_polls_enabled', whatsnext: 'collab_whatsnext_enabled' };
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
||||
for (const [feat, key] of Object.entries(mapping)) {
|
||||
if (features[feat] !== undefined) stmt.run(key, features[feat] ? 'true' : 'false');
|
||||
}
|
||||
return getCollabFeatures();
|
||||
}
|
||||
|
||||
// ── Packing Templates ──────────────────────────────────────────────────────
|
||||
|
||||
export function listPackingTemplates() {
|
||||
|
||||
@@ -170,6 +170,7 @@ export interface DayAccommodation {
|
||||
start_day_id: number;
|
||||
end_day_id: number;
|
||||
check_in: string | null;
|
||||
check_in_end: string | null;
|
||||
check_out: string | null;
|
||||
confirmation: string | null;
|
||||
notes: string | null;
|
||||
@@ -220,17 +221,18 @@ interface CreateAccommodationData {
|
||||
start_day_id: number;
|
||||
end_day_id: number;
|
||||
check_in?: string;
|
||||
check_in_end?: string;
|
||||
check_out?: string;
|
||||
confirmation?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export function createAccommodation(tripId: string | number, data: CreateAccommodationData) {
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes } = data;
|
||||
const { place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes } = data;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation || null, notes || null);
|
||||
'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_in_end, check_out, confirmation, notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'
|
||||
).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_in_end || null, check_out || null, confirmation || null, notes || null);
|
||||
|
||||
const accommodationId = result.lastInsertRowid;
|
||||
|
||||
@@ -239,6 +241,7 @@ export function createAccommodation(tripId: string | number, data: CreateAccommo
|
||||
const startDayDate = (db.prepare('SELECT date FROM days WHERE id = ?').get(start_day_id) as { date: string } | undefined)?.date || null;
|
||||
const meta: Record<string, string> = {};
|
||||
if (check_in) meta.check_in_time = check_in;
|
||||
if (check_in_end) meta.check_in_end_time = check_in_end;
|
||||
if (check_out) meta.check_out_time = check_out;
|
||||
db.prepare(`
|
||||
INSERT INTO reservations (trip_id, day_id, title, reservation_time, location, confirmation_number, notes, status, type, accommodation_id, metadata)
|
||||
@@ -258,25 +261,27 @@ export function getAccommodation(id: string | number, tripId: string | number) {
|
||||
|
||||
export function updateAccommodation(id: string | number, existing: DayAccommodation, fields: {
|
||||
place_id?: number; start_day_id?: number; end_day_id?: number;
|
||||
check_in?: string; check_out?: string; confirmation?: string; notes?: string;
|
||||
check_in?: string; check_in_end?: string; check_out?: string; confirmation?: string; notes?: string;
|
||||
}) {
|
||||
const newPlaceId = fields.place_id !== undefined ? fields.place_id : existing.place_id;
|
||||
const newStartDayId = fields.start_day_id !== undefined ? fields.start_day_id : existing.start_day_id;
|
||||
const newEndDayId = fields.end_day_id !== undefined ? fields.end_day_id : existing.end_day_id;
|
||||
const newCheckIn = fields.check_in !== undefined ? fields.check_in : existing.check_in;
|
||||
const newCheckInEnd = fields.check_in_end !== undefined ? fields.check_in_end : existing.check_in_end;
|
||||
const newCheckOut = fields.check_out !== undefined ? fields.check_out : existing.check_out;
|
||||
const newConfirmation = fields.confirmation !== undefined ? fields.confirmation : existing.confirmation;
|
||||
const newNotes = fields.notes !== undefined ? fields.notes : existing.notes;
|
||||
|
||||
db.prepare(
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
|
||||
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckOut, newConfirmation, newNotes, id);
|
||||
'UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = ?, check_in_end = ?, check_out = ?, confirmation = ?, notes = ? WHERE id = ?'
|
||||
).run(newPlaceId, newStartDayId, newEndDayId, newCheckIn, newCheckInEnd, newCheckOut, newConfirmation, newNotes, id);
|
||||
|
||||
// Sync check-in/out/confirmation to linked reservation
|
||||
const linkedRes = db.prepare('SELECT id, metadata FROM reservations WHERE accommodation_id = ?').get(Number(id)) as { id: number; metadata: string | null } | undefined;
|
||||
if (linkedRes) {
|
||||
const meta = linkedRes.metadata ? JSON.parse(linkedRes.metadata) : {};
|
||||
if (newCheckIn) meta.check_in_time = newCheckIn;
|
||||
if (newCheckInEnd) meta.check_in_end_time = newCheckInEnd;
|
||||
if (newCheckOut) meta.check_out_time = newCheckOut;
|
||||
db.prepare('UPDATE reservations SET metadata = ?, confirmation_number = COALESCE(?, confirmation_number) WHERE id = ?')
|
||||
.run(JSON.stringify(meta), newConfirmation || null, linkedRes.id);
|
||||
|
||||
@@ -123,9 +123,9 @@ export function createReservation(tripId: string | number, data: CreateReservati
|
||||
// Sync check-in/out to accommodation if linked
|
||||
if (accommodation_id && metadata) {
|
||||
const meta = typeof metadata === 'string' ? JSON.parse(metadata) : metadata;
|
||||
if (meta.check_in_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_out_time || null, accommodation_id);
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, accommodation_id);
|
||||
}
|
||||
if (confirmation_number) {
|
||||
db.prepare('UPDATE day_accommodations SET confirmation = COALESCE(?, confirmation) WHERE id = ?')
|
||||
@@ -257,9 +257,9 @@ export function updateReservation(id: string | number, tripId: string | number,
|
||||
const resolvedMeta = metadata !== undefined ? metadata : (current.metadata ? JSON.parse(current.metadata as string) : null);
|
||||
if (resolvedAccId && resolvedMeta) {
|
||||
const meta = typeof resolvedMeta === 'string' ? JSON.parse(resolvedMeta) : resolvedMeta;
|
||||
if (meta.check_in_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_out_time || null, resolvedAccId);
|
||||
if (meta.check_in_time || meta.check_in_end_time || meta.check_out_time) {
|
||||
db.prepare('UPDATE day_accommodations SET check_in = COALESCE(?, check_in), check_in_end = COALESCE(?, check_in_end), check_out = COALESCE(?, check_out) WHERE id = ?')
|
||||
.run(meta.check_in_time || null, meta.check_in_end_time || null, meta.check_out_time || null, resolvedAccId);
|
||||
}
|
||||
const resolvedConf = confirmation_number !== undefined ? confirmation_number : current.confirmation_number;
|
||||
if (resolvedConf) {
|
||||
|
||||
@@ -525,21 +525,6 @@ describe('Naver list import', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('POST /import/naver-list returns 403 when addon is disabled', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
testDb.prepare("UPDATE addons SET enabled = 0 WHERE id = 'naver_list_import'").run();
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/trips/${trip.id}/places/import/naver-list`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ url: 'https://naver.me/GYDpx3Wv' });
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.error).toContain('addon is disabled');
|
||||
});
|
||||
|
||||
it('POST /import/naver-list resolves shortlink, paginates, and creates places', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
Reference in New Issue
Block a user