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:
@@ -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";
|
||||
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user