refactor: consolidate day/trip grouping into shared endpoints via groupBy param

Merges potential-trips + potential-trip-assets into
potential-albums-dates + potential-albums-assets respectively, keyed
by a new ?groupBy=day|trip query param. The actual grouping logic is
extracted into two new helper files (potentialAlbumsGrouping.helper.ts,
potentialAlbumsAssets.helper.ts), each holding a day-mode and
trip-mode function the unified handlers call into.

Client-facing functions (listPotentialTrips, listPotentialTripAssets)
keep their existing names/signatures — only their internal path and a
groupBy param changed — so no frontend component call sites need
updating. Also drops the two now-unused LIST_POTENTIAL_TRIP*_PATH
route constants.

Addresses the maintainer's open caching-strategy question by removing
the need for a second endpoint/cache entirely: one endpoint per
concern, two grouping strategies.
This commit is contained in:
Varun Raj
2026-07-10 16:11:22 +00:00
parent ef3ce79c5b
commit a5a4528962
8 changed files with 253 additions and 252 deletions
+2 -2
View File
@@ -17,10 +17,10 @@ export const MERGE_PERSON_PATH = (id: string) => BASE_PROXY_ENDPOINT + "/people/
export const EXIF_DISTRIBUTION_PATH = (column: string) => BASE_API_ENDPOINT + "/analytics/exif/" + column;
// Albums
// Potential-albums grouping (day vs trip) and asset-detail (single day vs
// date range) share one endpoint each, distinguished by a `groupBy` param.
export const LIST_POTENTIAL_ALBUMS_DATES_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-dates";
export const LIST_POTENTIAL_ALBUMS_ASSETS_PATH = BASE_API_ENDPOINT + "/albums/potential-albums-assets";
export const LIST_POTENTIAL_TRIPS_PATH = BASE_API_ENDPOINT + "/albums/potential-trips";
export const LIST_POTENTIAL_TRIP_ASSETS_PATH = BASE_API_ENDPOINT + "/albums/potential-trip-assets";
export const LIST_ALBUMS_PATH = BASE_API_ENDPOINT + "/albums/list";
export const ALBUM_INFO_PATH = (id: string) => BASE_API_ENDPOINT + "/albums/" + id + "/info";
+4 -4
View File
@@ -1,4 +1,4 @@
import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, DELETE_ALBUMS_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH, LIST_POTENTIAL_TRIPS_PATH, LIST_POTENTIAL_TRIP_ASSETS_PATH, MERGE_ALBUMS_PATH, SHARE_ALBUMS_PATH } from "@/config/routes";
import { ADD_ASSETS_ALBUMS_PATH, ALBUM_ASSETS_PATH, ALBUM_INFO_PATH, ALBUM_PEOPLE_PATH, CREATE_ALBUM_PATH, DELETE_ALBUMS_PATH, LIST_ALBUMS_PATH, LIST_POTENTIAL_ALBUMS_ASSETS_PATH, LIST_POTENTIAL_ALBUMS_DATES_PATH, MERGE_ALBUMS_PATH, SHARE_ALBUMS_PATH } from "@/config/routes";
import { cleanUpAsset } from "@/helpers/asset.helper";
import API from "@/lib/api";
import { IAlbumCreate } from "@/types/album";
@@ -47,7 +47,7 @@ export interface IPotentialTripsResponse {
}
export const listPotentialTrips = async (filters?: IPotentialTripsFilters): Promise<IPotentialTripsResponse> => {
return API.get(LIST_POTENTIAL_TRIPS_PATH, filters);
return API.get(LIST_POTENTIAL_ALBUMS_DATES_PATH, { ...filters, groupBy: "trip" });
}
export const listPotentialTripAssets = async (
@@ -55,9 +55,9 @@ export const listPotentialTripAssets = async (
endDate: string,
city?: string | null,
): Promise<IAsset[]> => {
const params: Record<string, string> = { startDate, endDate };
const params: Record<string, string> = { startDate, endDate, groupBy: "trip" };
if (city) params.city = city;
return API.get(LIST_POTENTIAL_TRIP_ASSETS_PATH, params).then((assets) => assets.map(cleanUpAsset));
return API.get(LIST_POTENTIAL_ALBUMS_ASSETS_PATH, params).then((assets) => assets.map(cleanUpAsset));
}
export const listAlbums = async (filters?: { sortBy?: string, sortOrder?: string }) => {
+103
View File
@@ -0,0 +1,103 @@
// Asset-detail fetchers for the Potential Albums right-panel grid: either
// a single day, or a date range (trip). Both share one endpoint
// (potential-albums-assets.ts) via a `groupBy` param — this file holds the
// two DB-querying implementations.
import { db } from "@/config/db";
import { isFlipped } from "@/helpers/asset.helper";
import { sql } from "drizzle-orm";
const cleanRows = (rows: any[]) =>
rows.map((row: any) => ({
...row,
exifImageWidth: isFlipped(row.orientation || 0) ? row.exifImageHeight : row.exifImageWidth,
exifImageHeight: isFlipped(row.orientation || 0) ? row.exifImageWidth : row.exifImageHeight,
}));
const SELECT_ORPHAN_DAY = (date: string, ownerId: string) =>
sql.raw(`
SELECT
a."id",
a."ownerId",
a."type",
a."originalPath",
a."isFavorite",
a."duration",
a."originalFileName",
a."thumbhash",
a."deletedAt",
e."exifImageWidth",
e."exifImageHeight",
e."dateTimeOriginal",
e."orientation"
FROM
asset a
LEFT JOIN
album_asset aaa
ON a.id = aaa."assetId"
LEFT JOIN
asset_exif e
ON a.id = e."assetId"
WHERE
aaa."albumId" IS NULL
AND a."ownerId" = '${ownerId}'
AND e."dateTimeOriginal"::date = '${date}'
AND a."visibility" = 'timeline'
ORDER BY
e."dateTimeOriginal" DESC
`);
export const fetchDayAssets = async (ownerId: string, date: string) => {
const { rows } = await db.execute(SELECT_ORPHAN_DAY(date, ownerId));
return cleanRows(rows);
};
// Optional `city` narrows the result to assets whose EXIF city matches the
// trip's dominant city. Assets with NULL city are included alongside the
// match: they may be photos taken during the trip but without GPS metadata
// (screenshots, edited copies, older devices, etc.) and excluding them would
// lose real trip content. If `city` is not provided the date range alone
// determines membership.
const SELECT_ORPHAN_RANGE = (startDate: string, endDate: string, ownerId: string, city: string | null) => {
const cityClause = city
? `AND (e."city" = '${city.replace(/'/g, "''")}' OR e."city" IS NULL)`
: "";
return sql.raw(`
SELECT
a."id",
a."ownerId",
a."type",
a."originalPath",
a."isFavorite",
a."duration",
a."originalFileName",
a."thumbhash",
a."deletedAt",
e."exifImageWidth",
e."exifImageHeight",
e."dateTimeOriginal",
e."orientation",
e."city",
e."country"
FROM
asset a
LEFT JOIN
album_asset aaa
ON a.id = aaa."assetId"
LEFT JOIN
asset_exif e
ON a.id = e."assetId"
WHERE
aaa."albumId" IS NULL
AND a."ownerId" = '${ownerId}'
AND e."dateTimeOriginal"::date BETWEEN '${startDate}' AND '${endDate}'
AND a."visibility" = 'timeline'
${cityClause}
ORDER BY
e."dateTimeOriginal" ASC
`);
};
export const fetchTripAssets = async (ownerId: string, startDate: string, endDate: string, city: string | null) => {
const { rows } = await db.execute(SELECT_ORPHAN_RANGE(startDate, endDate, ownerId, city));
return cleanRows(rows);
};
@@ -0,0 +1,92 @@
// Grouping strategies for the Potential Albums list panel: group orphan
// assets either by single day, or by multi-day "trip" (consecutive away
// days). Both share one endpoint (potential-albums-dates.ts) via a
// `groupBy` param — this file holds the two DB-querying implementations.
import { db } from "@/config/db";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { assets } from "@/schema/assets.schema";
import { exif } from "@/schema/exif.schema";
import { and, avg, count, eq, isNotNull, isNull, sql } from "drizzle-orm";
import {
ClusterOptions,
DaySignal,
DEFAULT_CLUSTER_OPTIONS,
clusterTrips,
computeHome,
} from "./trips.helper";
export interface DayGroupRow {
date: string;
asset_count: number;
}
export const fetchDayGroups = async (ownerId: string): Promise<DayGroupRow[]> => {
const rows = await db.select({
date: sql`DATE(${exif.dateTimeOriginal})`,
asset_count: count(assets.id),
}).from(assets)
.leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetId))
.leftJoin(exif, eq(assets.id, exif.assetId))
.where(and(
eq(assets.ownerId, ownerId),
eq(assets.visibility, "timeline"),
isNull(albumsAssetsAssets.albumId),
isNotNull(exif.dateTimeOriginal),
))
.groupBy(sql`DATE(${exif.dateTimeOriginal})`) as DayGroupRow[];
return rows;
};
export interface TripGroupOptions extends Partial<ClusterOptions> {}
export const fetchTripGroups = async (ownerId: string, opts: TripGroupOptions = {}) => {
// Per-day aggregation: count, centroid, dominant city/country via PG MODE().
const rows = (await db
.select({
date: sql<string>`TO_CHAR(DATE(${exif.dateTimeOriginal}), 'YYYY-MM-DD')`,
assetCount: count(assets.id),
centroidLat: avg(exif.latitude),
centroidLon: avg(exif.longitude),
cityMode: sql<string | null>`MODE() WITHIN GROUP (ORDER BY ${exif.city})`,
countryMode: sql<string | null>`MODE() WITHIN GROUP (ORDER BY ${exif.country})`,
})
.from(assets)
.leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetId))
.leftJoin(exif, eq(assets.id, exif.assetId))
.where(
and(
eq(assets.ownerId, ownerId),
eq(assets.visibility, "timeline"),
isNull(albumsAssetsAssets.albumId),
isNotNull(exif.dateTimeOriginal)
)
)
.groupBy(sql`DATE(${exif.dateTimeOriginal})`)) as Array<{
date: string;
assetCount: number | string;
centroidLat: string | null;
centroidLon: string | null;
cityMode: string | null;
countryMode: string | null;
}>;
const days: DaySignal[] = rows.map((r) => ({
date: r.date,
assetCount: Number(r.assetCount),
cityMode: r.cityMode,
countryMode: r.countryMode,
centroidLat: r.centroidLat == null ? null : Number(r.centroidLat),
centroidLon: r.centroidLon == null ? null : Number(r.centroidLon),
}));
const home = computeHome(days);
const trips = clusterTrips(days, {
home,
homeRadiusKm: opts.homeRadiusKm ?? DEFAULT_CLUSTER_OPTIONS.homeRadiusKm,
minTripDays: opts.minTripDays ?? DEFAULT_CLUSTER_OPTIONS.minTripDays,
gapTolerance: opts.gapTolerance ?? DEFAULT_CLUSTER_OPTIONS.gapTolerance,
});
return { home, trips };
};
+32 -49
View File
@@ -1,61 +1,44 @@
import { db } from "@/config/db";
// Fetches orphan assets for the Potential Albums right-panel grid, either
// for a single day (default) or a date range (`?groupBy=trip`) — see
// src/helpers/potentialAlbumsAssets.helper.ts for the two implementations.
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { isFlipped } from "@/helpers/asset.helper";
import { sql } from "drizzle-orm";
import { fetchDayAssets, fetchTripAssets } from "@/helpers/potentialAlbumsAssets.helper";
import type { NextApiRequest, NextApiResponse } from "next";
const SELECT_ORPHAN_PHOTOS = (date: string, ownerId: string) =>
sql.raw(`
SELECT
a."id",
a."ownerId",
a."deviceId",
a."type",
a."originalPath",
a."isFavorite",
a."duration",
a."originalFileName",
a."thumbhash",
a."deletedAt",
e."exifImageWidth",
e."exifImageHeight",
e."dateTimeOriginal",
e."orientation"
FROM
asset a
LEFT JOIN
album_asset aaa
ON a.id = aaa."assetId"
LEFT JOIN
asset_exif e
ON a.id = e."assetId"
WHERE
aaa."albumId" IS NULL
AND a."ownerId" = '${ownerId}'
AND e."dateTimeOriginal"::date = '${date}'
AND a."visibility" = 'timeline'
ORDER BY
e."dateTimeOriginal" DESC
`);
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const currentUser = await getCurrentUser(req)
const { startDate } = req.query as { startDate: string };
const { rows } = await db.execute(SELECT_ORPHAN_PHOTOS(startDate, currentUser.id));
const currentUser = await getCurrentUser(req);
if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
const cleanedRows = rows.map((row: any) => {
return {
...row,
exifImageWidth: isFlipped(row.orientation || 0) ? row.exifImageHeight : row.exifImageWidth,
exifImageHeight: isFlipped(row.orientation || 0) ? row.exifImageWidth : row.exifImageHeight,
};
});
return res.status(200).json(cleanedRows);
const { groupBy = "day", startDate, endDate, city } = req.query as {
groupBy?: string;
startDate?: string;
endDate?: string;
city?: string;
};
if (groupBy === "trip") {
if (!startDate || !endDate) {
return res.status(400).json({ error: "startDate and endDate are required (YYYY-MM-DD)" });
}
// Interpolated into raw SQL below, so reject anything that isn't a
// plain date to keep this endpoint from becoming an injection vector.
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
if (!DATE_RE.test(startDate) || !DATE_RE.test(endDate)) {
return res.status(400).json({ error: "Invalid date format, expected YYYY-MM-DD" });
}
const cleaned = await fetchTripAssets(currentUser.id, startDate, endDate, city ?? null);
return res.status(200).json(cleaned);
}
if (!startDate) {
return res.status(400).json({ error: "startDate is required (YYYY-MM-DD)" });
}
const cleaned = await fetchDayAssets(currentUser.id, startDate);
return res.status(200).json(cleaned);
} catch (error: any) {
console.error(error);
res.status(500).json({
+20 -23
View File
@@ -1,37 +1,34 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import { db } from "@/config/db";
import { IPotentialAlbumsDatesResponse } from "@/handlers/api/album.handler";
// Groups orphan assets for the Potential Albums list panel, either by single
// day (default) or by trip (`?groupBy=trip`, consecutive away days) — see
// src/helpers/potentialAlbumsGrouping.helper.ts for the two implementations.
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { parseDate } from "@/helpers/date.helper";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { assets } from "@/schema/assets.schema";
import { exif } from "@/schema/exif.schema";
import { and, count, desc, eq, isNotNull, isNull, sql } from "drizzle-orm";
import { fetchDayGroups, fetchTripGroups } from "@/helpers/potentialAlbumsGrouping.helper";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const { sortBy = "date", sortOrder = "desc", minAssets = 1 } = req.query as any;
const currentUser = await getCurrentUser(req)
const rows = await db.select({
date: sql`DATE(${exif.dateTimeOriginal})`,
asset_count: desc(count(assets.id)),
}).from(assets)
.leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetId))
.leftJoin(exif, eq(assets.id, exif.assetId))
.where(and (
eq(assets.ownerId, currentUser.id),
eq(assets.visibility, "timeline"),
isNull(albumsAssetsAssets.albumId),
isNotNull(exif.dateTimeOriginal),
const currentUser = await getCurrentUser(req);
if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
))
.groupBy(sql`DATE(${exif.dateTimeOriginal})`) as IPotentialAlbumsDatesResponse[];
const { groupBy = "day" } = req.query as { groupBy?: string };
if (groupBy === "trip") {
const { homeRadiusKm, minTripDays, gapTolerance } = req.query as Record<string, string | undefined>;
const result = await fetchTripGroups(currentUser.id, {
homeRadiusKm: homeRadiusKm !== undefined ? Number(homeRadiusKm) : undefined,
minTripDays: minTripDays !== undefined ? Number(minTripDays) : undefined,
gapTolerance: gapTolerance !== undefined ? Number(gapTolerance) : undefined,
});
return res.status(200).json(result);
}
const { sortBy = "date", sortOrder = "desc", minAssets = 1 } = req.query as any;
const rows = await fetchDayGroups(currentUser.id);
const filteredRows = rows.filter((row) => Number(row.asset_count) >= Number(minAssets));
if (sortBy === "date") {
@@ -1,94 +0,0 @@
// Fetch orphan assets that fall within a date range (for the Potential Trips view).
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import { isFlipped } from "@/helpers/asset.helper";
import { sql } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
// Optional `city` narrows the result to assets whose EXIF city matches the
// trip's dominant city. Assets with NULL city are included alongside the
// match: they may be photos taken during the trip but without GPS metadata
// (screenshots, edited copies, older devices, etc.) and excluding them would
// lose real trip content. If `city` is not provided the date range alone
// determines membership.
const SELECT_ORPHAN_RANGE = (
startDate: string,
endDate: string,
ownerId: string,
city: string | null
) => {
const cityClause = city
? `AND (e."city" = '${city.replace(/'/g, "''")}' OR e."city" IS NULL)`
: "";
return sql.raw(`
SELECT
a."id",
a."ownerId",
a."type",
a."originalPath",
a."isFavorite",
a."duration",
a."originalFileName",
a."thumbhash",
a."deletedAt",
e."exifImageWidth",
e."exifImageHeight",
e."dateTimeOriginal",
e."orientation",
e."city",
e."country"
FROM
asset a
LEFT JOIN
album_asset aaa
ON a.id = aaa."assetId"
LEFT JOIN
asset_exif e
ON a.id = e."assetId"
WHERE
aaa."albumId" IS NULL
AND a."ownerId" = '${ownerId}'
AND e."dateTimeOriginal"::date BETWEEN '${startDate}' AND '${endDate}'
AND a."visibility" = 'timeline'
${cityClause}
ORDER BY
e."dateTimeOriginal" ASC
`);
};
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const currentUser = await getCurrentUser(req);
if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
const { startDate, endDate, city } = req.query as {
startDate?: string;
endDate?: string;
city?: string;
};
if (!startDate || !endDate) {
return res.status(400).json({ error: "startDate and endDate are required (YYYY-MM-DD)" });
}
// Basic format sanity (the strings are interpolated into the SQL literal, so
// reject anything that isn't YYYY-MM-DD to keep this endpoint from becoming
// an injection vector — same shape check the single-date endpoint should have.)
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
if (!DATE_RE.test(startDate) || !DATE_RE.test(endDate)) {
return res.status(400).json({ error: "Invalid date format, expected YYYY-MM-DD" });
}
const { rows } = await db.execute(
SELECT_ORPHAN_RANGE(startDate, endDate, currentUser.id, city ?? null)
);
const cleaned = rows.map((row: any) => ({
...row,
exifImageWidth: isFlipped(row.orientation || 0) ? row.exifImageHeight : row.exifImageWidth,
exifImageHeight: isFlipped(row.orientation || 0) ? row.exifImageWidth : row.exifImageHeight,
}));
return res.status(200).json(cleaned);
} catch (error: any) {
console.error(error);
return res.status(500).json({ error: error?.message });
}
}
-80
View File
@@ -1,80 +0,0 @@
// Potential trips — multi-day stretches of photos away from home.
// Companion to /api/albums/potential-albums-dates (which groups by single day).
import { db } from "@/config/db";
import { getCurrentUser } from "@/handlers/serverUtils/user.utils";
import {
DaySignal,
DEFAULT_CLUSTER_OPTIONS,
clusterTrips,
computeHome,
} from "@/helpers/trips.helper";
import { albumsAssetsAssets } from "@/schema/albumAssetsAssets.schema";
import { assets } from "@/schema/assets.schema";
import { exif } from "@/schema/exif.schema";
import { and, avg, count, eq, isNotNull, isNull, sql } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const {
homeRadiusKm = DEFAULT_CLUSTER_OPTIONS.homeRadiusKm,
minTripDays = DEFAULT_CLUSTER_OPTIONS.minTripDays,
gapTolerance = DEFAULT_CLUSTER_OPTIONS.gapTolerance,
} = req.query as Record<string, string | undefined>;
const currentUser = await getCurrentUser(req);
if (!currentUser) return res.status(401).json({ message: "Unauthorized" });
// Per-day aggregation: count, centroid, dominant city/country via PG MODE().
const rows = (await db
.select({
date: sql<string>`TO_CHAR(DATE(${exif.dateTimeOriginal}), 'YYYY-MM-DD')`,
assetCount: count(assets.id),
centroidLat: avg(exif.latitude),
centroidLon: avg(exif.longitude),
cityMode: sql<string | null>`MODE() WITHIN GROUP (ORDER BY ${exif.city})`,
countryMode: sql<string | null>`MODE() WITHIN GROUP (ORDER BY ${exif.country})`,
})
.from(assets)
.leftJoin(albumsAssetsAssets, eq(assets.id, albumsAssetsAssets.assetId))
.leftJoin(exif, eq(assets.id, exif.assetId))
.where(
and(
eq(assets.ownerId, currentUser.id),
eq(assets.visibility, "timeline"),
isNull(albumsAssetsAssets.albumId),
isNotNull(exif.dateTimeOriginal)
)
)
.groupBy(sql`DATE(${exif.dateTimeOriginal})`)) as Array<{
date: string;
assetCount: number | string;
centroidLat: string | null;
centroidLon: string | null;
cityMode: string | null;
countryMode: string | null;
}>;
const days: DaySignal[] = rows.map((r) => ({
date: r.date,
assetCount: Number(r.assetCount),
cityMode: r.cityMode,
countryMode: r.countryMode,
centroidLat: r.centroidLat == null ? null : Number(r.centroidLat),
centroidLon: r.centroidLon == null ? null : Number(r.centroidLon),
}));
const home = computeHome(days);
const trips = clusterTrips(days, {
home,
homeRadiusKm: Number(homeRadiusKm),
minTripDays: Number(minTripDays),
gapTolerance: Number(gapTolerance),
});
return res.status(200).json({ home, trips });
} catch (error: any) {
console.error(error);
return res.status(500).json({ error: error?.message });
}
}