diff --git a/.env_example b/.env_example index de3c8c1..ba390df 100644 --- a/.env_example +++ b/.env_example @@ -1,3 +1,5 @@ MBUS_API_KEY=YOUR_API_KEY RIDE_API_KEY=YOUR_API_KEY GOOGLE_APPLICATION_CREDENTIALS=secrets/YOUR_KEY.json +MONGODB_URI=mongodb://localhost:27017/mbus + diff --git a/package.json b/package.json index 64c5d38..35866e3 100644 --- a/package.json +++ b/package.json @@ -19,17 +19,19 @@ "express": "^4.19.2", "fast-json-stable-stringify": "^2.1.0", "fast-xml-parser": "^5.3.2", - "firebase-admin": "^13.6.0", + "firebase-admin": "^10.3.0", "lru-cache": "^11.2.5", + "mongoose": "^9.3.3", "ts-array-utils": "^0.5.0", "tsx": "^4.11.0", "zod": "^4.3.6" }, "devDependencies": { "@types/express": "^4.17.21", + "@types/mongoose": "^5.11.96", "@types/node": "^20.12.12", - "@vitest/coverage-v8": "^1.2.2", + "@vitest/coverage-v8": "^4.1.2", "typedoc": "^0.28.15", - "vitest": "^1.2.2" + "vitest": "^4.1.2" } } diff --git a/src/app.ts b/src/app.ts index 7690451..570fd4e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,16 +1,19 @@ import express from "express"; - -import mbus from "./routes/api" +import { connectToDatabase } from "./db/connection.js"; +import mbus from "./routes/api.js"; +import users from "./routes/users.js"; const app = express(); app.use(express.json()); app.use("/mbus/api/v3", mbus); +app.use("/mbus/api/v3/account", users); app.use("/docs", express.static("docs")); const PORT = process.env.PORT || 3000; - -app.listen(PORT, () => { - console.log(`Server running on port ${PORT}`); +connectToDatabase().then(() => { + app.listen(PORT, () => { + console.log(`Server running on port ${PORT}`); + }); }); \ No newline at end of file diff --git a/src/db/connection.ts b/src/db/connection.ts new file mode 100644 index 0000000..3ed965d --- /dev/null +++ b/src/db/connection.ts @@ -0,0 +1,8 @@ +import mongoose from "mongoose"; + +export async function connectToDatabase(): Promise { + const uri = process.env.MONGODB_URI; + if (!uri) throw new Error("MONGODB_URI is not set"); + await mongoose.connect(uri); + console.log("Connected to MongoDB"); +} \ No newline at end of file diff --git a/src/db/models/CommutePattern.ts b/src/db/models/CommutePattern.ts new file mode 100644 index 0000000..a5588ed --- /dev/null +++ b/src/db/models/CommutePattern.ts @@ -0,0 +1,27 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface ICommutePattern extends Document { + accountId: mongoose.Types.ObjectId; + fromStopId: string; + toStopId: string; + routeId: string; + daysOfWeek: number[]; // 0=Sun … 6=Sat + typicalHour: number; + confidence: number; + lastSeen: Date; +} + +const CommutePatternSchema = new Schema({ + accountId: { type: Schema.Types.ObjectId, required: true, ref: "InternalAccount" }, + fromStopId: { type: String, required: true }, + toStopId: { type: String, required: true }, + routeId: { type: String, required: true }, + daysOfWeek: { type: [Number], default: [] }, + typicalHour: { type: Number, required: true }, + confidence: { type: Number, default: 0 }, + lastSeen: { type: Date, default: Date.now }, +}); + +export const CommutePattern = mongoose.model( + "CommutePattern", CommutePatternSchema +); \ No newline at end of file diff --git a/src/db/models/InternalAccount.ts b/src/db/models/InternalAccount.ts new file mode 100644 index 0000000..8b4e5d7 --- /dev/null +++ b/src/db/models/InternalAccount.ts @@ -0,0 +1,21 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface IInternalAccount extends Document { + uniqname: string; + roles: string[]; + createdAt: Date; + lastLoginAt: Date; + active: boolean; +} + +const InternalAccountSchema = new Schema({ + uniqname: { type: String, required: true, unique: true }, + roles: { type: [String], default: [] }, + createdAt: { type: Date, default: Date.now }, + lastLoginAt: { type: Date, default: Date.now }, + active: { type: Boolean, default: true }, +}); + +export const InternalAccount = mongoose.model( + "InternalAccount", InternalAccountSchema +); \ No newline at end of file diff --git a/src/db/models/NotificationLog.ts b/src/db/models/NotificationLog.ts new file mode 100644 index 0000000..53a3314 --- /dev/null +++ b/src/db/models/NotificationLog.ts @@ -0,0 +1,25 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface INotificationLog extends Document { + accountId: mongoose.Types.ObjectId; + type: string; + routeId: string; + stopId: string; + sentAt: Date; + opened: boolean; + openedAt: Date | null; +} + +const NotificationLogSchema = new Schema({ + accountId: { type: Schema.Types.ObjectId, required: true, ref: "InternalAccount" }, + type: { type: String, required: true }, + routeId: { type: String, default: "" }, + stopId: { type: String, default: "" }, + sentAt: { type: Date, default: Date.now }, + opened: { type: Boolean, default: false }, + openedAt: { type: Date, default: null }, +}); + +export const NotificationLog = mongoose.model( + "NotificationLog", NotificationLogSchema +); \ No newline at end of file diff --git a/src/db/models/PersonalizationProfile.ts b/src/db/models/PersonalizationProfile.ts new file mode 100644 index 0000000..d1a71c1 --- /dev/null +++ b/src/db/models/PersonalizationProfile.ts @@ -0,0 +1,23 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface IPersonalizationProfile extends Document { + accountId: mongoose.Types.ObjectId; + topStopIds: string[]; + topRouteIds: string[]; + topBuildingIds: string[]; + peakUsageHours: number[]; + computedAt: Date; +} + +const PersonalizationProfileSchema = new Schema({ + accountId: { type: Schema.Types.ObjectId, required: true, unique: true, ref: "InternalAccount" }, + topStopIds: { type: [String], default: [] }, + topRouteIds: { type: [String], default: [] }, + topBuildingIds: { type: [String], default: [] }, + peakUsageHours: { type: [Number], default: [] }, + computedAt: { type: Date, default: Date.now }, +}); + +export const PersonalizationProfile = mongoose.model( + "PersonalizationProfile", PersonalizationProfileSchema +); \ No newline at end of file diff --git a/src/db/models/SavedStop.ts b/src/db/models/SavedStop.ts new file mode 100644 index 0000000..13a71af --- /dev/null +++ b/src/db/models/SavedStop.ts @@ -0,0 +1,23 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface ISavedStop extends Document { + accountId: mongoose.Types.ObjectId; + stopId: string; + customLabel: string; + pinned: boolean; + pinnedOrder: number; + savedAt: Date; +} + +const SavedStopSchema = new Schema({ + accountId: { type: Schema.Types.ObjectId, required: true, ref: "InternalAccount" }, + stopId: { type: String, required: true }, + customLabel: { type: String, default: "" }, + pinned: { type: Boolean, default: false }, + pinnedOrder: { type: Number, default: 0 }, + savedAt: { type: Date, default: Date.now }, +}); +// Prevent duplicate saves for same account + stop +SavedStopSchema.index({ accountId: 1, stopId: 1 }, { unique: true }); + +export const SavedStop = mongoose.model("SavedStop", SavedStopSchema); \ No newline at end of file diff --git a/src/db/models/SearchHistory.ts b/src/db/models/SearchHistory.ts new file mode 100644 index 0000000..ed0278b --- /dev/null +++ b/src/db/models/SearchHistory.ts @@ -0,0 +1,25 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface IUserSearchHistory extends Document { + accountId: mongoose.Types.ObjectId; + query: string; + resultType: string; + resultId: string; + searchCount: number; + lastSearchedAt: Date; +} + +const UserSearchHistorySchema = new Schema({ + accountId: { type: Schema.Types.ObjectId, required: true, ref: "InternalAccount" }, + query: { type: String, required: true }, + resultType: { type: String, default: "" }, + resultId: { type: String, default: "" }, + searchCount: { type: Number, default: 1 }, + lastSearchedAt: { type: Date, default: Date.now }, +}); +// Upsert key: one entry per (account, query, resultId) combo +UserSearchHistorySchema.index({ accountId: 1, query: 1, resultId: 1 }, { unique: true }); + +export const SearchHistory = mongoose.model( + "SearchHistory", UserSearchHistorySchema +); \ No newline at end of file diff --git a/src/db/models/TripHistory.ts b/src/db/models/TripHistory.ts new file mode 100644 index 0000000..eb2e61e --- /dev/null +++ b/src/db/models/TripHistory.ts @@ -0,0 +1,23 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface ITripHistory extends Document { + accountId: mongoose.Types.ObjectId; + routeId: string; + boardStopId: string; + alightStopId: string; + startedAt: Date; + endedAt: Date; + source: string; +} + +const TripHistorySchema = new Schema({ + accountId: { type: Schema.Types.ObjectId, required: true, ref: "InternalAccount" }, + routeId: { type: String, required: true }, + boardStopId: { type: String, required: true }, + alightStopId: { type: String, required: true }, + startedAt: { type: Date, required: true }, + endedAt: { type: Date, required: true }, + source: { type: String, default: "manual" }, +}); + +export const TripHistory = mongoose.model("TripHistory", TripHistorySchema); diff --git a/src/db/models/UserPreferences.ts b/src/db/models/UserPreferences.ts new file mode 100644 index 0000000..5bf1401 --- /dev/null +++ b/src/db/models/UserPreferences.ts @@ -0,0 +1,27 @@ +import mongoose, { Schema, Document } from "mongoose"; + +export interface IUserPreferences extends Document { + accountId: mongoose.Types.ObjectId; + defaultView: string; + notificationsEnabled: boolean; + notifyMinutesBefore: number; + preferredRouteIds: string[]; + accessibilityMode: boolean; + theme: string; + updatedAt: Date; +} + +const UserPreferencesSchema = new Schema({ + accountId: { type: Schema.Types.ObjectId, required: true, unique: true, ref: "InternalAccount" }, + defaultView: { type: String, default: "map" }, + notificationsEnabled: { type: Boolean, default: true }, + notifyMinutesBefore: { type: Number, default: 5 }, + preferredRouteIds: { type: [String], default: [] }, + accessibilityMode: { type: Boolean, default: false }, + theme: { type: String, default: "system" }, + updatedAt: { type: Date, default: Date.now }, +}); + +export const UserPreferences = mongoose.model( + "UserPreferences", UserPreferencesSchema +); \ No newline at end of file diff --git a/src/routes/users.ts b/src/routes/users.ts new file mode 100644 index 0000000..ae8a20c --- /dev/null +++ b/src/routes/users.ts @@ -0,0 +1,352 @@ +import express from "express"; +import * as z from "zod"; +import mongoose from "mongoose"; +import { InternalAccount } from "../db/models/InternalAccount.js"; +import { UserPreferences } from "../db/models/UserPreferences.js"; +import { SavedStop } from "../db/models/SavedStop.js"; +import { CommutePattern } from "../db/models/CommutePattern.js"; +import { SearchHistory } from "../db/models/SearchHistory.js"; +import { NotificationLog } from "../db/models/NotificationLog.js"; +import { TripHistory } from "../db/models/TripHistory.js"; +import { PersonalizationProfile } from "../db/models/PersonalizationProfile.js"; + +const router = express.Router(); + +function parseObjectId(id: string): mongoose.Types.ObjectId | null { + if (!mongoose.Types.ObjectId.isValid(id)) return null; + return new mongoose.Types.ObjectId(id); +} + +// POST /account — create account +const CreateAccountBody = z.object({ + uniqname: z.string(), + roles: z.array(z.string()).optional(), +}); + +export async function createAccount(req: express.Request, res: express.Response) { + const parsed = CreateAccountBody.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); + const existing = await InternalAccount.findOne({ uniqname: parsed.data.uniqname }); + if (existing) return res.status(409).json({ error: "Account already exists" }); + const account = await InternalAccount.create(parsed.data); + res.status(201).json(account); +} +router.post("/", createAccount); + +// GET /account/:accountId +export async function getAccount(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const account = await InternalAccount.findById(id); + if (!account) return res.status(404).json({ error: "Not found" }); + res.json(account); +} +router.get("/:accountId", getAccount); + +// PATCH /account/:accountId — update roles or active +const UpdateAccountBody = z.object({ + roles: z.array(z.string()).optional(), + active: z.boolean().optional(), + lastLoginAt: z.string().datetime().optional(), +}); + +export async function updateAccount(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const parsed = UpdateAccountBody.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); + const account = await InternalAccount.findByIdAndUpdate(id, parsed.data, { new: true }); + if (!account) return res.status(404).json({ error: "Not found" }); + res.json(account); +} +router.patch("/:accountId", updateAccount); + +// DELETE /account/:accountId — soft delete +export async function deactivateAccount(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const account = await InternalAccount.findByIdAndUpdate(id, { active: false }, { new: true }); + if (!account) return res.status(404).json({ error: "Not found" }); + res.json({ success: true }); +} +router.delete("/:accountId", deactivateAccount); + + +// GET /account/:accountId/preferences +export async function getPreferences(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const prefs = await UserPreferences.findOne({ accountId: id }); + if (!prefs) return res.status(404).json({ error: "Not found" }); + res.json(prefs); +} +router.get("/:accountId/preferences", getPreferences); + +// PUT /account/:accountId/preferences — upsert +const UpsertPreferencesBody = z.object({ + defaultView: z.string().optional(), + notificationsEnabled: z.boolean().optional(), + notifyMinutesBefore: z.number().int().optional(), + preferredRouteIds: z.array(z.string()).optional(), + accessibilityMode: z.boolean().optional(), + theme: z.string().optional(), +}); + +export async function upsertPreferences(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const parsed = UpsertPreferencesBody.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); + const prefs = await UserPreferences.findOneAndUpdate( + { accountId: id }, + { ...parsed.data, updatedAt: new Date() }, + { new: true, upsert: true } + ); + res.json(prefs); +} +router.put("/:accountId/preferences", upsertPreferences); + + +// GET /account/:accountId/saved-stops +export async function getSavedStops(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const stops = await SavedStop.find({ accountId: id }).sort({ pinnedOrder: 1, savedAt: -1 }); + res.json(stops); +} +router.get("/:accountId/saved-stops", getSavedStops); + +// POST /account/:accountId/saved-stops +const CreateSavedStopBody = z.object({ + stopId: z.string(), + customLabel: z.string().optional(), + pinned: z.boolean().optional(), + pinnedOrder: z.number().int().optional(), +}); + +export async function createSavedStop(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const parsed = CreateSavedStopBody.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); + try { + const stop = await SavedStop.create({ accountId: id, ...parsed.data }); + res.status(201).json(stop); + } catch (e: any) { + if (e.code === 11000) return res.status(409).json({ error: "Stop already saved" }); + throw e; + } +} +router.post("/:accountId/saved-stops", createSavedStop); + +// PATCH /account/:accountId/saved-stops/:stopId +const UpdateSavedStopBody = z.object({ + customLabel: z.string().optional(), + pinned: z.boolean().optional(), + pinnedOrder: z.number().int().optional(), +}); + +export async function updateSavedStop(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const parsed = UpdateSavedStopBody.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); + const stop = await SavedStop.findOneAndUpdate( + { accountId: id, stopId: req.params.stopId }, + parsed.data, + { new: true } + ); + if (!stop) return res.status(404).json({ error: "Not found" }); + res.json(stop); +} +router.patch("/:accountId/saved-stops/:stopId", updateSavedStop); + +// DELETE /account/:accountId/saved-stops/:stopId +export async function deleteSavedStop(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const result = await SavedStop.findOneAndDelete({ accountId: id, stopId: req.params.stopId }); + if (!result) return res.status(404).json({ error: "Not found" }); + res.json({ success: true }); +} +router.delete("/:accountId/saved-stops/:stopId", deleteSavedStop); + + +// GET /account/:accountId/search-history — optional ?limit +export async function getSearchHistory(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const limit = parseInt(req.query.limit as string) || 20; + const entries = await SearchHistory.find({ accountId: id }) + .sort({ lastSearchedAt: -1 }) + .limit(limit); + res.json(entries); +} +router.get("/:accountId/search-history", getSearchHistory); + +// POST /account/:accountId/search-history — upsert on (accountId + query + resultId) +const AddSearchEntryBody = z.object({ + query: z.string(), + resultType: z.string().optional(), + resultId: z.string().optional(), +}); + +export async function addSearchEntry(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const parsed = AddSearchEntryBody.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); + const { query, resultType = "", resultId = "" } = parsed.data; + const entry = await SearchHistory.findOneAndUpdate( + { accountId: id, query, resultId }, + { + $inc: { searchCount: 1 }, + $set: { lastSearchedAt: new Date(), resultType }, + $setOnInsert: { accountId: id, query, resultId }, + }, + { new: true, upsert: true } + ); + res.json(entry); +} +router.post("/:accountId/search-history", addSearchEntry); + +// DELETE /account/:accountId/search-history — clear all +export async function clearSearchHistory(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + await SearchHistory.deleteMany({ accountId: id }); + res.json({ success: true }); +} +router.delete("/:accountId/search-history", clearSearchHistory); + +// DELETE /account/:accountId/search-history/:entryId +export async function deleteSearchEntry(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + const entryId = parseObjectId(req.params.entryId); + if (!id || !entryId) return res.status(400).json({ error: "Invalid id" }); + const result = await SearchHistory.findOneAndDelete({ _id: entryId, accountId: id }); + if (!result) return res.status(404).json({ error: "Not found" }); + res.json({ success: true }); +} +router.delete("/:accountId/search-history/:entryId", deleteSearchEntry); + + +// GET /account/:accountId/notification-log — optional ?limit +export async function getNotificationLog(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const limit = parseInt(req.query.limit as string) || 50; + const logs = await NotificationLog.find({ accountId: id }) + .sort({ sentAt: -1 }) + .limit(limit); + res.json(logs); +} +router.get("/:accountId/notification-log", getNotificationLog); + +// POST /account/:accountId/notification-log +const CreateNotificationLogBody = z.object({ + type: z.string(), + routeId: z.string().optional(), + stopId: z.string().optional(), +}); + +export async function createNotificationLog(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const parsed = CreateNotificationLogBody.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); + const log = await NotificationLog.create({ accountId: id, ...parsed.data }); + res.status(201).json(log); +} +router.post("/:accountId/notification-log", createNotificationLog); + +// PATCH /account/:accountId/notification-log/:logId — mark as opened +export async function markNotificationOpened(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + const logId = parseObjectId(req.params.logId); + if (!id || !logId) return res.status(400).json({ error: "Invalid id" }); + const log = await NotificationLog.findOneAndUpdate( + { _id: logId, accountId: id }, + { opened: true, openedAt: new Date() }, + { new: true } + ); + if (!log) return res.status(404).json({ error: "Not found" }); + res.json(log); +} +router.patch("/:accountId/notification-log/:logId", markNotificationOpened); + + +// GET /account/:accountId/trip-history +export async function getTripHistory(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const trips = await TripHistory.find({ accountId: id }).sort({ startedAt: -1 }); + res.json(trips); +} +router.get("/:accountId/trip-history", getTripHistory); + +// POST /account/:accountId/trip-history +const CreateTripBody = z.object({ + routeId: z.string(), + boardStopId: z.string(), + alightStopId: z.string(), + startedAt: z.string().datetime(), + endedAt: z.string().datetime(), + source: z.string().optional(), +}); + +export async function createTripHistory(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const parsed = CreateTripBody.safeParse(req.body); + if (!parsed.success) return res.status(400).json({ error: parsed.error.format() }); + const trip = await TripHistory.create({ accountId: id, ...parsed.data }); + res.status(201).json(trip); +} +router.post("/:accountId/trip-history", createTripHistory); + +// DELETE /account/:accountId/trip-history/:tripId +export async function deleteTripHistory(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + const tripId = parseObjectId(req.params.tripId); + if (!id || !tripId) return res.status(400).json({ error: "Invalid id" }); + const result = await TripHistory.findOneAndDelete({ _id: tripId, accountId: id }); + if (!result) return res.status(404).json({ error: "Not found" }); + res.json({ success: true }); +} +router.delete("/:accountId/trip-history/:tripId", deleteTripHistory); + + +// GET /account/:accountId/commute-patterns +export async function getCommutePatterns(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const patterns = await CommutePattern.find({ accountId: id }); + res.json(patterns); +} +router.get("/:accountId/commute-patterns", getCommutePatterns); + +// DELETE /account/:accountId/commute-patterns/:patternId +export async function deleteCommutePattern(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + const patternId = parseObjectId(req.params.patternId); + if (!id || !patternId) return res.status(400).json({ error: "Invalid id" }); + const result = await CommutePattern.findOneAndDelete({ _id: patternId, accountId: id }); + if (!result) return res.status(404).json({ error: "Not found" }); + res.json({ success: true }); +} +router.delete("/:accountId/commute-patterns/:patternId", deleteCommutePattern); + + +// GET /account/:accountId/personalization-profile +export async function getPersonalizationProfile(req: express.Request, res: express.Response) { + const id = parseObjectId(req.params.accountId); + if (!id) return res.status(400).json({ error: "Invalid accountId" }); + const profile = await PersonalizationProfile.findOne({ accountId: id }); + if (!profile) return res.status(404).json({ error: "Not found" }); + res.json(profile); +} +router.get("/:accountId/personalization-profile", getPersonalizationProfile); + + +export default router;