Files
famapp/src/modules/garden/server/queries.ts
T
ginnoir c6a34f7471 feat: share links visible on plants/containers; container image gallery
- Fix ShareButton silently swallowing errors from createShareLink; now
  shows inline error text so failures are visible to the user
- Add getShareLinksForEntity server action and EntityShareLink type to
  _core/share.ts
- Add ShareLinkList component — renders active share links per entity
  with per-row Revoke; renders nothing when empty
- Wire ShareLinkList into plant and container detail pages (loaded
  server-side in parallel with the entity fetch)
- Add images jsonb column to garden_containers schema + migration 0018
- Add addContainerImage / removeContainerImage / setContainerPrimaryImage
  server actions mirroring the plant image pattern (10-image cap, first
  upload auto-sets cover)
- Update ContainerDetailDto, listContainers, getContainer to include images
- Rewrite ContainerDetail with Info/Gallery tabs; Gallery tab mirrors
  plant gallery (3-col grid, star/X overlays, upload button, counter)
- Update ContainerShareData and container renderSharedView to show cover
  image hero and secondary image grid on public share pages
2026-06-02 20:15:29 -05:00

625 lines
18 KiB
TypeScript

import { and, desc, eq, lte, sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
export type ContainerDto = {
id: string;
householdId: string;
name: string;
type: string;
locationNotes: string | null;
coverImageUrl: string | null;
images: string[];
plantCount: number;
createdAt: string;
updatedAt: string;
};
export type ContainerDetailDto = ContainerDto & {
plants: PlantSummaryDto[];
};
export type PlantSummaryDto = {
id: string;
name: string;
scientificName: string | null;
healthStatus: string;
primaryImageUrl: string | null;
category: string;
};
export async function listContainers(): Promise<ContainerDto[]> {
const { household } = await getCurrentSession();
const rows = await db
.select({
id: gardenContainers.id,
householdId: gardenContainers.householdId,
name: gardenContainers.name,
type: gardenContainers.type,
locationNotes: gardenContainers.locationNotes,
coverImageUrl: gardenContainers.coverImageUrl,
images: gardenContainers.images,
createdAt: gardenContainers.createdAt,
updatedAt: gardenContainers.updatedAt,
plantCount: sql<number>`count(${gardenPlants.id})::int`,
})
.from(gardenContainers)
.leftJoin(gardenPlants, eq(gardenPlants.containerId, gardenContainers.id))
.where(eq(gardenContainers.householdId, household.id))
.groupBy(gardenContainers.id)
.orderBy(gardenContainers.name);
return rows.map((r) => ({
...r,
images: r.images ?? [],
plantCount: r.plantCount ?? 0,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}));
}
export async function getContainer(id: string): Promise<ContainerDetailDto | null> {
const { household } = await getCurrentSession();
const [container] = await db
.select({
id: gardenContainers.id,
householdId: gardenContainers.householdId,
name: gardenContainers.name,
type: gardenContainers.type,
locationNotes: gardenContainers.locationNotes,
coverImageUrl: gardenContainers.coverImageUrl,
images: gardenContainers.images,
createdAt: gardenContainers.createdAt,
updatedAt: gardenContainers.updatedAt,
plantCount: sql<number>`(select count(*)::int from garden_plants where container_id = ${gardenContainers.id})`,
})
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, household.id)))
.limit(1);
if (!container) return null;
const plants = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
scientificName: gardenPlants.scientificName,
healthStatus: gardenPlants.healthStatus,
primaryImageUrl: gardenPlants.primaryImageUrl,
category: gardenPlants.category,
})
.from(gardenPlants)
.where(eq(gardenPlants.containerId, id))
.orderBy(desc(gardenPlants.name));
return {
id: container.id,
householdId: container.householdId,
name: container.name,
type: container.type,
locationNotes: container.locationNotes,
coverImageUrl: container.coverImageUrl,
images: container.images ?? [],
plantCount: container.plantCount ?? 0,
createdAt: container.createdAt.toISOString(),
updatedAt: container.updatedAt.toISOString(),
plants,
};
}
export async function canAccessContainer(id: string, householdId: string): Promise<boolean> {
const [row] = await db
.select({ id: gardenContainers.id })
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, householdId)))
.limit(1);
return !!row;
}
export async function searchContainers(query: string, householdId: string) {
const rows = await db
.select({ id: gardenContainers.id, name: gardenContainers.name })
.from(gardenContainers)
.where(
and(
eq(gardenContainers.householdId, householdId),
sql`${gardenContainers.name} ilike ${`%${query}%`}`,
),
)
.limit(10);
return rows.map((r) => ({
id: r.id,
title: r.name,
url: `/garden/containers/${r.id}`,
}));
}
// ─── Plant queries ────────────────────────────────────────────────────────────
export type PlantListItemDto = {
id: string;
containerId: string | null;
containerName: string | null;
name: string;
healthStatus: string;
primaryImageUrl: string | null;
category: string;
lastWateredAt: string | null;
hasOverdueCare: boolean;
};
export type CareLogSummaryDto = {
id: string;
careType: string;
notes: string | null;
performedAt: string;
};
export type CareScheduleSummaryDto = {
id: string;
careType: string;
intervalDays: number;
nextDueAt: string | null;
enabled: boolean;
};
export type PlantDetailDto = {
id: string;
householdId: string;
containerId: string | null;
containerName: string | null;
name: string;
scientificName: string | null;
speciesId: string | null;
category: string;
notes: string | null;
acquisitionDate: string | null;
growthStage: string | null;
healthStatus: string;
sunlight: string | null;
wateringNotes: string | null;
fertilizingNotes: string | null;
primaryImageUrl: string | null;
images: string[];
recentCareLogs: CareLogSummaryDto[];
activeSchedules: CareScheduleSummaryDto[];
createdAt: string;
updatedAt: string;
};
export async function listPlants({ containerId }: { containerId?: string } = {}): Promise<
PlantListItemDto[]
> {
const { household } = await getCurrentSession();
const filter = containerId
? and(eq(gardenPlants.householdId, household.id), eq(gardenPlants.containerId, containerId))
: eq(gardenPlants.householdId, household.id);
const rows = await db
.select({
id: gardenPlants.id,
containerId: gardenPlants.containerId,
containerName: gardenContainers.name,
name: gardenPlants.name,
healthStatus: gardenPlants.healthStatus,
primaryImageUrl: gardenPlants.primaryImageUrl,
category: gardenPlants.category,
lastWateredAt: sql<string | null>`(
select performed_at::text from garden_care_logs
where plant_id = ${gardenPlants.id}
and care_type = 'watering'
order by performed_at desc
limit 1
)`,
hasOverdueCare: sql<boolean>`exists(
select 1 from garden_care_schedules
where plant_id = ${gardenPlants.id}
and enabled = true
and next_due_at < now()
)`,
})
.from(gardenPlants)
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
.where(filter)
.orderBy(gardenContainers.name, gardenPlants.name);
return rows.map((r) => ({
...r,
containerName: r.containerName ?? null,
lastWateredAt: r.lastWateredAt ?? null,
hasOverdueCare: !!r.hasOverdueCare,
}));
}
export async function getPlant(id: string): Promise<PlantDetailDto | null> {
const { household } = await getCurrentSession();
const [row] = await db
.select({
id: gardenPlants.id,
householdId: gardenPlants.householdId,
containerId: gardenPlants.containerId,
containerName: gardenContainers.name,
name: gardenPlants.name,
scientificName: gardenPlants.scientificName,
speciesId: gardenPlants.speciesId,
category: gardenPlants.category,
notes: gardenPlants.notes,
acquisitionDate: gardenPlants.acquisitionDate,
growthStage: gardenPlants.growthStage,
healthStatus: gardenPlants.healthStatus,
sunlight: gardenPlants.sunlight,
wateringNotes: gardenPlants.wateringNotes,
fertilizingNotes: gardenPlants.fertilizingNotes,
primaryImageUrl: gardenPlants.primaryImageUrl,
images: gardenPlants.images,
createdAt: gardenPlants.createdAt,
updatedAt: gardenPlants.updatedAt,
})
.from(gardenPlants)
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, household.id)))
.limit(1);
if (!row) return null;
const careLogs = await db
.select({
id: gardenCareLogs.id,
careType: gardenCareLogs.careType,
notes: gardenCareLogs.notes,
performedAt: gardenCareLogs.performedAt,
})
.from(gardenCareLogs)
.where(eq(gardenCareLogs.plantId, id))
.orderBy(desc(gardenCareLogs.performedAt))
.limit(5);
const schedules = await db
.select({
id: gardenCareSchedules.id,
careType: gardenCareSchedules.careType,
intervalDays: gardenCareSchedules.intervalDays,
nextDueAt: gardenCareSchedules.nextDueAt,
enabled: gardenCareSchedules.enabled,
})
.from(gardenCareSchedules)
.where(and(eq(gardenCareSchedules.plantId, id), eq(gardenCareSchedules.enabled, true)));
return {
id: row.id,
householdId: row.householdId,
containerId: row.containerId,
containerName: row.containerName ?? null,
name: row.name,
scientificName: row.scientificName,
speciesId: row.speciesId,
category: row.category,
notes: row.notes,
acquisitionDate: row.acquisitionDate,
growthStage: row.growthStage,
healthStatus: row.healthStatus,
sunlight: row.sunlight,
wateringNotes: row.wateringNotes,
fertilizingNotes: row.fertilizingNotes,
primaryImageUrl: row.primaryImageUrl,
images: row.images,
recentCareLogs: careLogs.map((l) => ({
id: l.id,
careType: l.careType,
notes: l.notes,
performedAt: l.performedAt.toISOString(),
})),
activeSchedules: schedules.map((s) => ({
id: s.id,
careType: s.careType,
intervalDays: s.intervalDays,
nextDueAt: s.nextDueAt?.toISOString() ?? null,
enabled: s.enabled,
})),
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
export async function searchPlants(query: string, householdId: string) {
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
scientificName: gardenPlants.scientificName,
})
.from(gardenPlants)
.where(
and(
eq(gardenPlants.householdId, householdId),
sql`(${gardenPlants.name} || ' ' || coalesce(${gardenPlants.scientificName}, '')) ilike ${`%${query}%`}`,
),
)
.limit(10);
return rows.map((r) => ({
id: r.id,
title: r.name + (r.scientificName ? ` (${r.scientificName})` : ""),
url: `/garden/plants/${r.id}`,
}));
}
// ─── Care queries ─────────────────────────────────────────────────────────────
export type CareLogDto = {
id: string;
careType: string;
notes: string | null;
performedAt: string;
performedBy: string | null;
};
export type CareScheduleDto = {
id: string;
careType: string;
intervalDays: number;
lastPerformedAt: string | null;
nextDueAt: string | null;
enabled: boolean;
daysUntilDue: number | null;
isOverdue: boolean;
};
export async function getCareLogs(plantId: string, limit = 20): Promise<CareLogDto[]> {
const { household } = await getCurrentSession();
const rows = await db
.select({
id: gardenCareLogs.id,
careType: gardenCareLogs.careType,
notes: gardenCareLogs.notes,
performedAt: gardenCareLogs.performedAt,
performedBy: gardenCareLogs.performedBy,
})
.from(gardenCareLogs)
.where(and(eq(gardenCareLogs.plantId, plantId), eq(gardenCareLogs.householdId, household.id)))
.orderBy(desc(gardenCareLogs.performedAt))
.limit(limit);
return rows.map((r) => ({
id: r.id,
careType: r.careType,
notes: r.notes,
performedAt: r.performedAt.toISOString(),
performedBy: r.performedBy,
}));
}
export async function getCareSchedules(plantId: string): Promise<CareScheduleDto[]> {
const { household } = await getCurrentSession();
const rows = await db
.select()
.from(gardenCareSchedules)
.where(
and(
eq(gardenCareSchedules.plantId, plantId),
eq(gardenCareSchedules.householdId, household.id),
),
)
.orderBy(gardenCareSchedules.careType);
const now = new Date();
return rows.map((s) => {
const next = s.nextDueAt;
const daysUntilDue = next
? Math.ceil((next.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
: null;
return {
id: s.id,
careType: s.careType,
intervalDays: s.intervalDays,
lastPerformedAt: s.lastPerformedAt?.toISOString() ?? null,
nextDueAt: next?.toISOString() ?? null,
enabled: s.enabled,
daysUntilDue,
isOverdue: daysUntilDue !== null && daysUntilDue < 0,
};
});
}
export type OverduePlantDto = {
id: string;
name: string;
primaryImageUrl: string | null;
mostOverdueAt: Date;
};
export async function getOverduePlants(householdId: string): Promise<OverduePlantDto[]> {
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
primaryImageUrl: gardenPlants.primaryImageUrl,
mostOverdueAt: sql<Date>`min(${gardenCareSchedules.nextDueAt})`,
})
.from(gardenPlants)
.innerJoin(
gardenCareSchedules,
and(
eq(gardenCareSchedules.plantId, gardenPlants.id),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
)
.where(eq(gardenPlants.householdId, householdId))
.groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl)
.orderBy(sql`min(${gardenCareSchedules.nextDueAt})`);
return rows;
}
export type CareDueSoonDto = {
id: string;
name: string;
primaryImageUrl: string | null;
nextDueAt: Date;
};
export async function getCareDueSoon(
householdId: string,
withinDays: number,
): Promise<CareDueSoonDto[]> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + withinDays);
const rows = await db
.select({
id: gardenPlants.id,
name: gardenPlants.name,
primaryImageUrl: gardenPlants.primaryImageUrl,
nextDueAt: sql<Date>`min(${gardenCareSchedules.nextDueAt})`,
})
.from(gardenPlants)
.innerJoin(
gardenCareSchedules,
and(
eq(gardenCareSchedules.plantId, gardenPlants.id),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, cutoff),
),
)
.where(eq(gardenPlants.householdId, householdId))
.groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl)
.orderBy(sql`min(${gardenCareSchedules.nextDueAt})`);
return rows;
}
// ─── Widget queries ───────────────────────────────────────────────────────────
export type CareDueWidgetRow = {
plantId: string;
plantName: string;
scheduleId: string;
careType: string;
nextDueAt: Date | null;
daysUntilDue: number;
};
export async function getCareDueWidgetRows(
householdId: string,
containerIds: "all" | string[],
daysAhead: number,
): Promise<CareDueWidgetRow[]> {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() + daysAhead);
const baseConditions = [
eq(gardenCareSchedules.householdId, householdId),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, cutoff),
];
const containerFilter =
containerIds !== "all" && containerIds.length > 0
? sql`${gardenPlants.containerId} = ANY(ARRAY[${sql.join(
containerIds.map((id) => sql`${id}::uuid`),
sql`, `,
)}])`
: undefined;
const where =
containerFilter !== undefined
? and(...baseConditions, containerFilter)
: and(...baseConditions);
const rows = await db
.select({
plantId: gardenPlants.id,
plantName: gardenPlants.name,
scheduleId: gardenCareSchedules.id,
careType: gardenCareSchedules.careType,
nextDueAt: gardenCareSchedules.nextDueAt,
})
.from(gardenCareSchedules)
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
.where(where)
.orderBy(gardenCareSchedules.nextDueAt);
const now = Date.now();
return rows.map((r) => ({
plantId: r.plantId,
plantName: r.plantName,
scheduleId: r.scheduleId,
careType: r.careType,
nextDueAt: r.nextDueAt,
daysUntilDue: r.nextDueAt
? Math.ceil((r.nextDueAt.getTime() - now) / (1000 * 60 * 60 * 24))
: 0,
}));
}
export type GardenOverviewStats = {
plantCount: number;
containerCount: number;
overdueCount: number;
nextCare: { plantName: string; careType: string; daysUntilDue: number } | null;
};
export async function getGardenOverviewStats(householdId: string): Promise<GardenOverviewStats> {
const [plantRow, containerRow] = await Promise.all([
db
.select({ count: sql<number>`count(*)::int` })
.from(gardenPlants)
.where(eq(gardenPlants.householdId, householdId)),
db
.select({ count: sql<number>`count(*)::int` })
.from(gardenContainers)
.where(eq(gardenContainers.householdId, householdId)),
]);
const plantCount = plantRow[0]?.count ?? 0;
const containerCount = containerRow[0]?.count ?? 0;
const overdueRows = await db
.select({ count: sql<number>`count(*)::int` })
.from(gardenCareSchedules)
.where(
and(
eq(gardenCareSchedules.householdId, householdId),
eq(gardenCareSchedules.enabled, true),
lte(gardenCareSchedules.nextDueAt, sql`now()`),
),
);
const overdueCount = overdueRows[0]?.count ?? 0;
const nextRows = await db
.select({
plantName: gardenPlants.name,
careType: gardenCareSchedules.careType,
nextDueAt: gardenCareSchedules.nextDueAt,
})
.from(gardenCareSchedules)
.innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id))
.where(
and(eq(gardenCareSchedules.householdId, householdId), eq(gardenCareSchedules.enabled, true)),
)
.orderBy(gardenCareSchedules.nextDueAt)
.limit(1);
const next = nextRows[0];
const now = Date.now();
const nextCare = next?.nextDueAt
? {
plantName: next.plantName,
careType: next.careType,
daysUntilDue: Math.ceil((next.nextDueAt.getTime() - now) / (1000 * 60 * 60 * 24)),
}
: null;
return { plantCount, containerCount, overdueCount, nextCare };
}