Skip to content

feat(galeria): admin photo upload with S3 and DB persistence - #257

Open
agustin-sanc wants to merge 9 commits into
testingfrom
feature/gallery-admin-uploads
Open

feat(galeria): admin photo upload with S3 and DB persistence#257
agustin-sanc wants to merge 9 commits into
testingfrom
feature/gallery-admin-uploads

Conversation

@agustin-sanc

@agustin-sanc agustin-sanc commented Apr 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Adds a GalleryPhoto model with title, takenAt (date-only), location, imageUrl, and soft-delete support
  • Admins can upload photos via a form that uses presigned S3 PUT URLs (folder: 'gallery', reusing the existing FileUpload component and getPresignedUrl action)
  • Admins can edit and delete (soft) each photo via per-card controls in the gallery grid
  • Lightbox now displays title, date, and location for all users
  • /galeria converted from a client-only page with a hardcoded array to a server component that fetches from the DB; share URLs (?foto=<cuid>) still work
  • Empty state shown when no photos have been uploaded yet

Test plan

  • Sign in as ADMIN, navigate to /galeria — "Subir foto" button is visible
  • Upload a photo (JPG/PNG), fill title, date, location — confirm S3 PUT 200, new row in DB, card appears without refresh
  • Open lightbox — title, formatted date, and location are shown
  • Open share URL ?foto=<id> in a new tab — correct photo loads
  • Edit a photo via the pencil icon — toast fires and card updates
  • Delete a photo via the trash icon — confirm dialog, card disappears, deletedAt set in DB
  • Sign in as REGULAR user — no upload/edit/delete controls visible; direct server action call throws "No tienes permisos"
  • Run pnpm lint — no errors

Summary by CodeRabbit

Release Notes

  • New Features
    • Added photo management capabilities for administrators to upload, edit, and delete gallery photos
    • Gallery photos now include metadata such as title, location, and capture date
    • Enhanced gallery display with location-based search filtering and chronological sorting by capture date

@coderabbitai

coderabbitai Bot commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: a832424b-b4b5-4f2b-be54-f90ced7a59d4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This PR introduces a complete gallery photo management system featuring database schema with soft-delete support, server actions for CRUD operations with admin authorization, form-based photo create/edit functionality, and UI components for displaying, filtering, and managing gallery photos with role-based admin controls.

Changes

Cohort / File(s) Summary
Database Schema Setup
prisma/migrations/20260417000000_add_gallery_photo/migration.sql, prisma/schema.prisma
Added new GalleryPhoto table with id, metadata fields (title, location, takenAt, imageUrl), lifecycle timestamps (createdAt, updatedAt, optional deletedAt for soft deletes), and indexes on takenAt and deletedAt for efficient querying.
Validation Schema
src/schemas/gallery-photo-schema.ts
Defined Zod validation schemas for photo creation and updates with field constraints (title 3–200 chars, location 2–150 chars, takenAt as Date, imageUrl as URL), plus inferred TypeScript input types.
Server Actions - CRUD Operations
src/actions/gallery/create-gallery-photo.ts, src/actions/gallery/update-gallery-photo.ts, src/actions/gallery/delete-gallery-photo.ts, src/actions/gallery/get-gallery-photo.ts, src/actions/gallery/get-gallery-photos.ts
Implemented five server actions: createGalleryPhoto and updateGalleryPhoto validate input and check admin authorization before modifying records, deleteGalleryPhoto soft-deletes by setting deletedAt, and read operations (getGalleryPhotos, getGalleryPhoto) filter out soft-deleted records. All write operations revalidate the /galeria route.
Gallery Page & Components
src/app/(platform)/galeria/page.tsx, src/components/photo-gallery/gallery.tsx, src/components/photo-gallery/photo-card.tsx, src/components/photo-gallery/photo-dialog.tsx, src/components/photo-gallery/gallery-photo-form-dialog.tsx, src/components/photo-gallery/delete-photo-dialog.tsx
Converted gallery page to async server component fetching photos and auth session server-side. Gallery component now receives photos data array and currentUser role, implements location/date-based filtering alongside title search, and conditionally renders admin-only upload/edit/delete controls. Components updated to use Prisma GalleryPhoto type and string photo IDs; new GalleryPhotoFormDialog handles create/edit with form validation and DeletePhotoDialog provides deletion confirmation.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Form as GalleryPhotoFormDialog
    participant SA as Server Action<br/>(Create/Update)
    participant DB as Prisma/Database
    participant Cache as Next.js Cache
    participant UI as Gallery UI

    User->>Form: Submit form
    Form->>Form: Validate with Zod
    Form->>SA: Call createGalleryPhoto/<br/>updateGalleryPhoto
    SA->>SA: Verify sessionId & Admin role
    SA->>SA: Validate data
    SA->>DB: Create/Update galleryPhoto
    DB-->>SA: Return record
    SA->>Cache: revalidatePath('/galeria')
    Cache-->>SA: Cache invalidated
    SA-->>Form: Return photo
    Form->>Form: Show success toast
    Form-->>User: Close dialog
    User->>UI: Gallery refreshes
    UI->>UI: New/updated photo visible
Loading
sequenceDiagram
    participant User
    participant Dialog as DeletePhotoDialog
    participant SA as deleteGalleryPhoto<br/>Server Action
    participant DB as Prisma/Database
    participant Cache as Next.js Cache
    participant UI as Gallery UI

    User->>Dialog: Click delete, confirm
    Dialog->>Dialog: Set isDeleting=true
    Dialog->>SA: Call deleteGalleryPhoto(id)
    SA->>SA: Verify sessionId & Admin role
    SA->>DB: Update galleryPhoto<br/>set deletedAt=now()
    DB-->>SA: Return soft-deleted record
    SA->>Cache: revalidatePath('/galeria')
    Cache-->>SA: Cache invalidated
    SA-->>Dialog: Return {success:true}
    Dialog->>Dialog: Show success toast
    Dialog-->>User: Close dialog
    User->>UI: Gallery refreshes
    UI->>UI: Photo removed from list
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 Hop, hop, hooray!
New photos bloom in the digital hay,
Admins now wield their deletion might,
While galleries flourish, soft-delete in sight!
A schema so clean, indexed for speed,
Just what our fuzzy database needs! 📸

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(galeria): admin photo upload with S3 and DB persistence' directly describes the main change: adding admin photo upload functionality with S3 integration and database persistence for gallery photos.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/gallery-admin-uploads

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@agustin-sanc

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 21, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (4)
prisma/migrations/20260417000000_add_gallery_photo/migration.sql (1)

15-19: Create the index used by the active-photo list query.

The runtime query filters active rows and orders by date; a composite index avoids relying on two independent indexes plus a sort.

Proposed migration change
--- CreateIndex
-CREATE INDEX "GalleryPhoto_takenAt_idx" ON "GalleryPhoto"("takenAt");
-
--- CreateIndex
-CREATE INDEX "GalleryPhoto_deletedAt_idx" ON "GalleryPhoto"("deletedAt");
+-- CreateIndex
+CREATE INDEX "GalleryPhoto_deletedAt_takenAt_idx" ON "GalleryPhoto"("deletedAt", "takenAt");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/migrations/20260417000000_add_gallery_photo/migration.sql` around
lines 15 - 19, Add a composite index on GalleryPhoto to support the active-photo
list query by covering the filter and sort together: create an index on
("deletedAt","takenAt") (e.g. name it GalleryPhoto_deletedAt_takenAt_idx)
instead of two separate single-column indexes; update the migration's index
creation section (where the current CREATE INDEX "GalleryPhoto_takenAt_idx" and
"GalleryPhoto_deletedAt_idx" are defined) to add this composite index and remove
the redundant single-column indexes so the query can use the composite index for
the WHERE deletedAt IS NULL ORDER BY takenAt DESC pattern.
prisma/schema.prisma (1)

330-331: Use a composite index for the gallery photo queries.

getGalleryPhotos() filters by deletedAt: null and sorts by takenAt; a composite index @@index([deletedAt, takenAt]) is more efficient than separate indexes for this access pattern.

Proposed schema change
-  @@index([takenAt])
-  @@index([deletedAt])
+  @@index([deletedAt, takenAt])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/schema.prisma` around lines 330 - 331, Replace the two separate schema
indexes with a single composite index for the gallery photos access pattern:
change the existing @@index([takenAt]) and @@index([deletedAt]) entries to a
combined @@index([deletedAt, takenAt]) so queries like getGalleryPhotos() that
filter on deletedAt = null and order by takenAt can use the composite index;
locate the indexes in the model that contains the deletedAt and takenAt fields
and update them accordingly.
src/components/photo-gallery/photo-dialog.tsx (1)

55-70: Extract duplicated filename slug logic.

This exact slug+.jpg construction is duplicated in src/components/photo-gallery/photo-card.tsx (lines 38–42). If the sanitization rules ever change (e.g., to preserve Spanish accents like á/ñ, which [^\w-] currently strips), both copies need to stay in sync. Consider moving it next to downloadImage in src/lib/download-helper.ts.

♻️ Proposed shared helper
// src/lib/download-helper.ts (new export)
export function buildPhotoFileName(title: string, ext = 'jpg'): string {
  return title
    .toLowerCase()
    .replace(/\s+/g, '-')
    .replace(/[^\w-]/g, '')
    .concat(`.${ext}`);
}
-      const fileName = currentPhoto.title
-        .toLowerCase()
-        .replace(/\s+/g, '-')
-        .replace(/[^\w-]/g, '')
-        .concat('.jpg');
-
-      await downloadImage(currentPhoto.imageUrl, fileName);
+      await downloadImage(currentPhoto.imageUrl, buildPhotoFileName(currentPhoto.title));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/photo-gallery/photo-dialog.tsx` around lines 55 - 70,
Duplicate filename-slug logic used in handleDownload (photo-dialog.tsx) and in
photo-card.tsx should be moved into a single helper next to downloadImage; add
and export a function like buildPhotoFileName(title: string, ext = 'jpg') in
src/lib/download-helper.ts that encapsulates the
.toLowerCase().replace(/\s+/g,'-').replace(/[^\w-]/g,'').concat(`.${ext}`)
behavior, then replace the inline slug creation in handleDownload and the
corresponding code in PhotoCard with calls to buildPhotoFileName and import it
alongside downloadImage so both components use the shared implementation.
src/components/photo-gallery/gallery.tsx (1)

14-16: Prefer the Prisma Role enum over a loose string.

GalleryUser.role: string weakens the currentUser?.role === 'ADMIN' check (typos compile silently). getCurrentSession() returns a Prisma User with role: Role enum, and the type is lost when assigned to GalleryUser. Since @prisma/client is already imported in this file, reuse the enum:

-import { GalleryPhoto } from '@prisma/client';
+import { GalleryPhoto, Role } from '@prisma/client';

 interface GalleryUser {
-  role: string;
+  role: Role;
 }

Alternatively, use Pick<User, 'role'> for exact type compatibility.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/photo-gallery/gallery.tsx` around lines 14 - 16, Replace the
loose string type on the GalleryUser interface with Prisma's Role enum (or use
Pick<User, 'role'>) so role comparisons are type-safe: change interface
GalleryUser { role: string } to use the imported Role type from `@prisma/client`
(or declare GalleryUser = Pick<User,'role'>) and ensure any assignment from
getCurrentSession() preserves the enum type so expressions like
currentUser?.role === 'ADMIN' are checked against the enum rather than a plain
string.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@prisma/schema.prisma`:
- Line 323: The takenAt column is defined as a DateTime (timestamp) but the UI
and Zod schema treat it as a date-only value; change the Prisma model field
named takenAt from DateTime to a date-only native type for Postgres (use the
PostgreSQL DATE native type) and update the generated migration so the DB column
is DATE instead of TIMESTAMP(3); search for the takenAt field in the Prisma
schema, the gallery-photo-form-dialog.tsx usage and the Zod schema
(z.coerce.date()) to ensure type consistency across model, validation, and UI
before running prisma migrate to apply the DATE change.

In `@src/actions/gallery/create-gallery-photo.ts`:
- Around line 15-22: The action currently authorizes by fetching the session via
prisma.session.findUnique and checking session.user.role but ignores
session.expires; update authorization to reject expired sessions by comparing
session.expires to the current time and only allow if expires > now. Refactor
this into a shared helper (e.g., requireAdminSession(sessionId) referenced by
create-gallery-photo, deleteGalleryPhoto, and updateGalleryPhoto) that: looks up
the session with include: { user: true }, verifies session exists, checks
session.expires is in the future, and confirms user.role === 'ADMIN'; throw a
clear authorization error if any check fails and use that helper in the
mentioned action files.

In `@src/actions/gallery/delete-gallery-photo.ts`:
- Around line 14-19: The session lookup currently uses
prisma.session.findUnique({ where: { id: sessionId }, include: { user: true } })
and does not verify Session.expires, allowing expired admin sessions to remain
valid; change the query to only return non-expired sessions by adding an expires
condition (e.g., include expires: { gt: new Date() } or combine into where so
the session must have id = sessionId AND expires > now) and keep include: {
user: true }; apply the same fix to the session queries used in
deleteGalleryPhoto, updateGalleryPhoto, and createGalleryPhoto so all three
mutations validate session expiration before authorizing admin actions.
- Around line 23-26: The soft-delete call currently uses
prisma.galleryPhoto.update({ where: { id }, data: { deletedAt: new Date() } })
which will throw if the row is missing and will update already-deleted rows;
change this to prisma.galleryPhoto.updateMany({ where: { id, deletedAt: null },
data: { deletedAt: new Date() } }) so the operation is idempotent and safe under
races, and ensure the surrounding deleteGalleryPhoto handler treats zero
affected rows as a success (no-op) rather than an error.

In `@src/actions/gallery/update-gallery-photo.ts`:
- Around line 15-20: In update-gallery-photo.ts the session lookup using
prisma.session.findUnique only verifies the row exists and role, not whether the
session has expired; update the check to ensure session.expires is still in the
future (either by adding expires: { gt: new Date() } to the
prisma.session.findUnique where clause or by checking session.expires > new
Date() after retrieval) and return/throw an unauthorized error if the session is
missing or expired; apply the same expires validation pattern wherever
prisma.session.findUnique is used to prevent expired sessions from mutating
data.
- Around line 26-34: The update currently uses prisma.galleryPhoto.update on
validatedData.id and doesn't guard against soft-deleted rows; change the
mutation to prisma.galleryPhoto.updateMany with where: { id: validatedData.id,
deletedAt: null } and the same data payload, then check the returned count
(affectedRows) to ensure a row was updated; if count is 0, throw or return a
not-found/error, otherwise fetch the updated record (e.g.,
prisma.galleryPhoto.findUnique or findFirst with id) to return the updated
photo. Ensure you reference validatedData.id and the same data fields (title,
location, takenAt, imageUrl) when creating the updateMany payload.

In `@src/app/`(platform)/galeria/page.tsx:
- Line 40: The page currently passes the entire session?.user object into the
Gallery component (Gallery photos={photos} currentUser={session?.user ?? null});
instead pass only the minimal shape required (the role) to avoid serializing
sensitive user fields: extract or compute a small object or primitive containing
only role (e.g., role: session?.user?.role ?? null) and pass that instead (or
rename prop to currentUserRole/currentRole), and update Gallery's prop
type/signature (Gallery props and any usage inside Gallery) to accept this
minimal shape/primitive rather than a full user object.

In `@src/components/photo-gallery/gallery.tsx`:
- Around line 44-52: The effect in Gallery that reads searchParams and sets
selected photo (useEffect referencing searchParams, filteredPhotos,
setSelectedPhotoIndex) doesn't clear selectedPhotoIndex when the photo id from
?foto= is not found, leaving selectedPhotoIndex pointing to a stale index;
update that effect to check if photoIndex === -1 and in that case clear the
selection (call setSelectedPhotoIndex(null or -1 consistent with your state
type) and/or close the dialog state so isDialogOpen becomes false) so
PhotoDialog never receives an out-of-range index; ensure the chosen sentinel
value matches other code paths that read selectedPhotoIndex.

In `@src/schemas/gallery-photo-schema.ts`:
- Line 13: The imageUrl field currently uses z.string().url() which allows any
external URL; change the validation in the gallery photo schema (the imageUrl
property) to enforce the configured gallery origin and key prefix instead of a
generic URL: validate that the parsed URL's origin equals your configured
gallery CDN/bucket origin (e.g., GALLERY_CDN_ORIGIN or getGalleryBaseUrl()) and
that pathname startsWith '/gallery/' (or the configured key prefix), returning a
clear error message if either check fails; implement this as a z.string().refine
or z.preprocess that parses new URL(imageUrl) and checks origin and pathname so
only S3/CDN gallery uploads are accepted.
- Line 12: The takenAt field currently uses z.coerce.date() and must instead
validate a date-only string first; change takenAt to accept a string matching
the YYYY-MM-DD regex (e.g. /^\d{4}-\d{2}-\d{2}$/) using z.string().regex(..., {
required_error: ... }), then transform or parse that validated string into a
Date (via .transform(s => new Date(s)) or z.preprocess) and keep the same
required error message; update the schema field named takenAt to implement this
string-validation-then-transform flow so only date-only values are accepted
before creating the Date object.

---

Nitpick comments:
In `@prisma/migrations/20260417000000_add_gallery_photo/migration.sql`:
- Around line 15-19: Add a composite index on GalleryPhoto to support the
active-photo list query by covering the filter and sort together: create an
index on ("deletedAt","takenAt") (e.g. name it
GalleryPhoto_deletedAt_takenAt_idx) instead of two separate single-column
indexes; update the migration's index creation section (where the current CREATE
INDEX "GalleryPhoto_takenAt_idx" and "GalleryPhoto_deletedAt_idx" are defined)
to add this composite index and remove the redundant single-column indexes so
the query can use the composite index for the WHERE deletedAt IS NULL ORDER BY
takenAt DESC pattern.

In `@prisma/schema.prisma`:
- Around line 330-331: Replace the two separate schema indexes with a single
composite index for the gallery photos access pattern: change the existing
@@index([takenAt]) and @@index([deletedAt]) entries to a combined
@@index([deletedAt, takenAt]) so queries like getGalleryPhotos() that filter on
deletedAt = null and order by takenAt can use the composite index; locate the
indexes in the model that contains the deletedAt and takenAt fields and update
them accordingly.

In `@src/components/photo-gallery/gallery.tsx`:
- Around line 14-16: Replace the loose string type on the GalleryUser interface
with Prisma's Role enum (or use Pick<User, 'role'>) so role comparisons are
type-safe: change interface GalleryUser { role: string } to use the imported
Role type from `@prisma/client` (or declare GalleryUser = Pick<User,'role'>) and
ensure any assignment from getCurrentSession() preserves the enum type so
expressions like currentUser?.role === 'ADMIN' are checked against the enum
rather than a plain string.

In `@src/components/photo-gallery/photo-dialog.tsx`:
- Around line 55-70: Duplicate filename-slug logic used in handleDownload
(photo-dialog.tsx) and in photo-card.tsx should be moved into a single helper
next to downloadImage; add and export a function like buildPhotoFileName(title:
string, ext = 'jpg') in src/lib/download-helper.ts that encapsulates the
.toLowerCase().replace(/\s+/g,'-').replace(/[^\w-]/g,'').concat(`.${ext}`)
behavior, then replace the inline slug creation in handleDownload and the
corresponding code in PhotoCard with calls to buildPhotoFileName and import it
alongside downloadImage so both components use the shared implementation.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 96dbf053-26a0-4fa4-ab43-5cf4f842cf9c

📥 Commits

Reviewing files that changed from the base of the PR and between 74c666f and 58d04c5.

📒 Files selected for processing (14)
  • prisma/migrations/20260417000000_add_gallery_photo/migration.sql
  • prisma/schema.prisma
  • src/actions/gallery/create-gallery-photo.ts
  • src/actions/gallery/delete-gallery-photo.ts
  • src/actions/gallery/get-gallery-photo.ts
  • src/actions/gallery/get-gallery-photos.ts
  • src/actions/gallery/update-gallery-photo.ts
  • src/app/(platform)/galeria/page.tsx
  • src/components/photo-gallery/delete-photo-dialog.tsx
  • src/components/photo-gallery/gallery-photo-form-dialog.tsx
  • src/components/photo-gallery/gallery.tsx
  • src/components/photo-gallery/photo-card.tsx
  • src/components/photo-gallery/photo-dialog.tsx
  • src/schemas/gallery-photo-schema.ts

Comment thread prisma/schema.prisma Outdated
model GalleryPhoto {
id String @id @default(cuid())
title String
takenAt DateTime

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify all GalleryPhoto date storage declarations use date-only semantics.
rg -n -C2 'model GalleryPhoto|takenAt\s+DateTime|"takenAt"\s+(TIMESTAMP|DATE)' prisma

Repository: programaconnosotros/pcn-website

Length of output: 905


🏁 Script executed:

# Search for all references to takenAt in the codebase
rg -n 'takenAt' --type ts --type tsx --type js --type graphql -A 2 -B 2

Repository: programaconnosotros/pcn-website

Length of output: 102


🏁 Script executed:

# Check if there are any PR descriptions or comments in the repo
find . -maxdepth 3 -type f \( -name "*.md" -o -name "*.txt" \) | head -20 | xargs grep -l "takenAt\|gallery\|date-only" 2>/dev/null || echo "No matches in docs"

Repository: programaconnosotros/pcn-website

Length of output: 93


🏁 Script executed:

# Check the migration file more thoroughly
cat prisma/migrations/20260417000000_add_gallery_photo/migration.sql

Repository: programaconnosotros/pcn-website

Length of output: 635


🏁 Script executed:

# Search for all references to takenAt in the codebase without type filter
rg -n 'takenAt' -A 2 -B 2

Repository: programaconnosotros/pcn-website

Length of output: 5075


🏁 Script executed:

# Check the schema.prisma for full GalleryPhoto model and any queries that use it
cat -n prisma/schema.prisma | grep -A 20 "model GalleryPhoto"

Repository: programaconnosotros/pcn-website

Length of output: 759


🏁 Script executed:

# Search for any comments in the migration or schema explaining the intent
rg -n 'date.only|date-only|date only|photo.*date' -i prisma/

Repository: programaconnosotros/pcn-website

Length of output: 57


Change takenAt to a date-only column.

The code explicitly strips the time component from takenAt (see gallery-photo-form-dialog.tsx line 45: new Date(photo.takenAt.toISOString().substring(0, 10))), and the Zod schema uses z.coerce.date(). Storing this as TIMESTAMP(3) with time precision is wasteful and confusing. Use Prisma's PostgreSQL date native type:

Proposed schema change
-  takenAt   DateTime
+  takenAt   DateTime  `@db.Date`

Update the migration to use DATE instead of TIMESTAMP(3).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
takenAt DateTime
takenAt DateTime `@db.Date`
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@prisma/schema.prisma` at line 323, The takenAt column is defined as a
DateTime (timestamp) but the UI and Zod schema treat it as a date-only value;
change the Prisma model field named takenAt from DateTime to a date-only native
type for Postgres (use the PostgreSQL DATE native type) and update the generated
migration so the DB column is DATE instead of TIMESTAMP(3); search for the
takenAt field in the Prisma schema, the gallery-photo-form-dialog.tsx usage and
the Zod schema (z.coerce.date()) to ensure type consistency across model,
validation, and UI before running prisma migrate to apply the DATE change.

Comment thread src/actions/gallery/create-gallery-photo.ts
Comment on lines +14 to +19
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: true },
});

if (!session || session.user.role !== 'ADMIN') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify all new gallery admin mutations enforce Session.expires.
rg -n -C4 'prisma\.session\.find(Unique|First)|expires:\s*\{\s*gt|createGalleryPhoto|updateGalleryPhoto|deleteGalleryPhoto' src/actions/gallery prisma/schema.prisma

Repository: programaconnosotros/pcn-website

Length of output: 5382


🏁 Script executed:

cat prisma/schema.prisma | grep -A 15 "^model Session"

Repository: programaconnosotros/pcn-website

Length of output: 478


🏁 Script executed:

cat -n src/actions/gallery/delete-gallery-photo.ts

Repository: programaconnosotros/pcn-website

Length of output: 999


🏁 Script executed:

cat -n src/actions/gallery/update-gallery-photo.ts

Repository: programaconnosotros/pcn-website

Length of output: 1389


🏁 Script executed:

cat -n src/actions/gallery/create-gallery-photo.ts

Repository: programaconnosotros/pcn-website

Length of output: 1344


Add session expiration validation to all gallery mutations.

Expired admin sessions can still delete, update, and create photos because the queries don't check the Session.expires field. The session record persists in the database after expiration, allowing an expired cookie to remain functional indefinitely for admin operations.

All three mutations—deleteGalleryPhoto, updateGalleryPhoto, and createGalleryPhoto—must validate expiration when querying the session:

Required auth query fix for all three files
-  const session = await prisma.session.findUnique({
-    where: { id: sessionId },
+  const session = await prisma.session.findFirst({
+    where: {
+      id: sessionId,
+      expires: { gt: new Date() },
+    },
     include: { user: true },
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/gallery/delete-gallery-photo.ts` around lines 14 - 19, The
session lookup currently uses prisma.session.findUnique({ where: { id: sessionId
}, include: { user: true } }) and does not verify Session.expires, allowing
expired admin sessions to remain valid; change the query to only return
non-expired sessions by adding an expires condition (e.g., include expires: {
gt: new Date() } or combine into where so the session must have id = sessionId
AND expires > now) and keep include: { user: true }; apply the same fix to the
session queries used in deleteGalleryPhoto, updateGalleryPhoto, and
createGalleryPhoto so all three mutations validate session expiration before
authorizing admin actions.

Comment on lines +23 to +26
await prisma.galleryPhoto.update({
where: { id },
data: { deletedAt: new Date() },
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify delete/update paths avoid raw Prisma not-found behavior on soft-deleted gallery rows.
rg -n -C4 'galleryPhoto\.(update|updateMany)|deletedAt:\s*null|return \{ success: true \}' src/actions/gallery

Repository: programaconnosotros/pcn-website

Length of output: 2659


🏁 Script executed:

cat -n src/actions/gallery/delete-gallery-photo.ts

Repository: programaconnosotros/pcn-website

Length of output: 999


🏁 Script executed:

# Check if there are try-catch or error handlers in the file or similar gallery actions
rg -n 'try|catch|throw|error' src/actions/gallery/ -A2 -B2

Repository: programaconnosotros/pcn-website

Length of output: 2045


🏁 Script executed:

# Check if there are other soft-delete operations in the codebase for comparison
rg -n 'data:\s*\{\s*deletedAt' src/actions -A1 -B1

Repository: programaconnosotros/pcn-website

Length of output: 277


Use updateMany with deletedAt: null to make soft delete idempotent.

The current update({ where: { id } }) will throw if the row doesn't exist, and overwrites deletedAt on already-deleted rows. Since this is exposed to race conditions (multiple delete requests, stale UI), use updateMany with the condition where: { id, deletedAt: null } and return success when no rows match.

Proposed fix
-  await prisma.galleryPhoto.update({
-    where: { id },
+  const result = await prisma.galleryPhoto.updateMany({
+    where: { id, deletedAt: null },
     data: { deletedAt: new Date() },
   });
+
+  if (result.count === 0) {
+    return { success: true };
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/gallery/delete-gallery-photo.ts` around lines 23 - 26, The
soft-delete call currently uses prisma.galleryPhoto.update({ where: { id },
data: { deletedAt: new Date() } }) which will throw if the row is missing and
will update already-deleted rows; change this to
prisma.galleryPhoto.updateMany({ where: { id, deletedAt: null }, data: {
deletedAt: new Date() } }) so the operation is idempotent and safe under races,
and ensure the surrounding deleteGalleryPhoto handler treats zero affected rows
as a success (no-op) rather than an error.

Comment on lines +15 to +20
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: true },
});

if (!session || session.user.role !== 'ADMIN') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify all new gallery admin mutations enforce Session.expires.
rg -n -C4 'prisma\.session\.find(Unique|First)|expires:\s*\{\s*gt|createGalleryPhoto|updateGalleryPhoto|deleteGalleryPhoto' src/actions/gallery prisma/schema.prisma

Repository: programaconnosotros/pcn-website

Length of output: 5382


🏁 Script executed:

cat prisma/schema.prisma | grep -A 20 "model Session"

Repository: programaconnosotros/pcn-website

Length of output: 556


🏁 Script executed:

#!/bin/bash
# Search for auth middleware, guards, or utilities that might validate session expiration
fd -e ts -e js src/ | xargs grep -l "expires\|session.*valid\|auth.*middleware" | head -20

Repository: programaconnosotros/pcn-website

Length of output: 396


🏁 Script executed:

#!/bin/bash
# Check all session.find calls in the codebase to see if any check expiration
rg -n "session\.find(Unique|First|Many)" --type ts -C 2

Repository: programaconnosotros/pcn-website

Length of output: 18511


Add Session.expires validation to prevent expired sessions from mutating gallery photos.

Session expiration is not checked before allowing mutations. Any expired admin session ID can still edit photos because the query only verifies the session row exists, not whether expires is still valid.

Proposed fix
  const session = await prisma.session.findUnique({
-   where: { id: sessionId },
+   where: {
+     id: sessionId,
+     expires: { gt: new Date() },
+   },
    include: { user: true },
  });

Note: This vulnerability exists across the entire application wherever prisma.session.findUnique() is used without expires validation.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const session = await prisma.session.findUnique({
where: { id: sessionId },
include: { user: true },
});
if (!session || session.user.role !== 'ADMIN') {
const session = await prisma.session.findUnique({
where: {
id: sessionId,
expires: { gt: new Date() },
},
include: { user: true },
});
if (!session || session.user.role !== 'ADMIN') {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/gallery/update-gallery-photo.ts` around lines 15 - 20, In
update-gallery-photo.ts the session lookup using prisma.session.findUnique only
verifies the row exists and role, not whether the session has expired; update
the check to ensure session.expires is still in the future (either by adding
expires: { gt: new Date() } to the prisma.session.findUnique where clause or by
checking session.expires > new Date() after retrieval) and return/throw an
unauthorized error if the session is missing or expired; apply the same expires
validation pattern wherever prisma.session.findUnique is used to prevent expired
sessions from mutating data.

Comment on lines +26 to +34
const photo = await prisma.galleryPhoto.update({
where: { id: validatedData.id },
data: {
title: validatedData.title,
location: validatedData.location,
takenAt: validatedData.takenAt,
imageUrl: validatedData.imageUrl,
},
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify gallery writes consistently guard against soft-deleted rows.
rg -n -C4 'galleryPhoto\.(update|updateMany|findFirst)|deletedAt:\s*null' src/actions/gallery

Repository: programaconnosotros/pcn-website

Length of output: 2454


Guard the write against soft-deleted photos.

The read operations correctly exclude soft-deleted photos with where: { deletedAt: null }, but the update operation at line 26 does not. This allows stale dialogs or direct server action calls to mutate soft-deleted photos. Switch to updateMany with deletedAt: null in the where clause and verify the update affected a row before retrieving it:

Proposed fix
-  const photo = await prisma.galleryPhoto.update({
-    where: { id: validatedData.id },
+  const updateResult = await prisma.galleryPhoto.updateMany({
+    where: { id: validatedData.id, deletedAt: null },
     data: {
       title: validatedData.title,
       location: validatedData.location,
       takenAt: validatedData.takenAt,
       imageUrl: validatedData.imageUrl,
     },
   });
+
+  if (updateResult.count === 0) {
+    throw new Error('La foto no existe o fue eliminada');
+  }
+
+  const photo = await prisma.galleryPhoto.findFirst({
+    where: { id: validatedData.id, deletedAt: null },
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/actions/gallery/update-gallery-photo.ts` around lines 26 - 34, The update
currently uses prisma.galleryPhoto.update on validatedData.id and doesn't guard
against soft-deleted rows; change the mutation to prisma.galleryPhoto.updateMany
with where: { id: validatedData.id, deletedAt: null } and the same data payload,
then check the returned count (affectedRows) to ensure a row was updated; if
count is 0, throw or return a not-found/error, otherwise fetch the updated
record (e.g., prisma.galleryPhoto.findUnique or findFirst with id) to return the
updated photo. Ensure you reference validatedData.id and the same data fields
(title, location, takenAt, imageUrl) when creating the updateMany payload.

Comment thread src/app/(platform)/galeria/page.tsx
Comment on lines 44 to +52
useEffect(() => {
if (initialPhotoId) {
const photoIndex = sortedPhotos.findIndex((photo) => photo.id === initialPhotoId);
const photoId = searchParams.get('foto');
if (photoId) {
const photoIndex = filteredPhotos.findIndex((p) => p.id === photoId);
if (photoIndex !== -1) {
setSelectedPhotoIndex(photoIndex);
}
}
}, [initialPhotoId, sortedPhotos]);
}, [searchParams, filteredPhotos]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Stale selectedPhotoIndex when the active photo gets filtered out.

If the lightbox is open on photo X (e.g., via ?foto=X) and the user types a search that excludes X, filteredPhotos.findIndex returns -1 and the effect leaves selectedPhotoIndex pointing at an index that no longer exists in filteredPhotos. PhotoDialog then does photos[currentPhotoIndex] === undefined and returns null, but isDialogOpen in Gallery remains true, so the dialog/close UI isn't rendered and state is inconsistent.

♻️ Proposed fix — reset selection when the current photo isn't in the filtered list
   useEffect(() => {
     const photoId = searchParams.get('foto');
-    if (photoId) {
-      const photoIndex = filteredPhotos.findIndex((p) => p.id === photoId);
-      if (photoIndex !== -1) {
-        setSelectedPhotoIndex(photoIndex);
-      }
-    }
+    if (!photoId) {
+      setSelectedPhotoIndex(-1);
+      return;
+    }
+    const photoIndex = filteredPhotos.findIndex((p) => p.id === photoId);
+    setSelectedPhotoIndex(photoIndex);
   }, [searchParams, filteredPhotos]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
useEffect(() => {
if (initialPhotoId) {
const photoIndex = sortedPhotos.findIndex((photo) => photo.id === initialPhotoId);
const photoId = searchParams.get('foto');
if (photoId) {
const photoIndex = filteredPhotos.findIndex((p) => p.id === photoId);
if (photoIndex !== -1) {
setSelectedPhotoIndex(photoIndex);
}
}
}, [initialPhotoId, sortedPhotos]);
}, [searchParams, filteredPhotos]);
useEffect(() => {
const photoId = searchParams.get('foto');
if (!photoId) {
setSelectedPhotoIndex(-1);
return;
}
const photoIndex = filteredPhotos.findIndex((p) => p.id === photoId);
setSelectedPhotoIndex(photoIndex);
}, [searchParams, filteredPhotos]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/photo-gallery/gallery.tsx` around lines 44 - 52, The effect in
Gallery that reads searchParams and sets selected photo (useEffect referencing
searchParams, filteredPhotos, setSelectedPhotoIndex) doesn't clear
selectedPhotoIndex when the photo id from ?foto= is not found, leaving
selectedPhotoIndex pointing to a stale index; update that effect to check if
photoIndex === -1 and in that case clear the selection (call
setSelectedPhotoIndex(null or -1 consistent with your state type) and/or close
the dialog state so isDialogOpen becomes false) so PhotoDialog never receives an
out-of-range index; ensure the chosen sentinel value matches other code paths
that read selectedPhotoIndex.

Comment thread src/schemas/gallery-photo-schema.ts
Comment thread src/schemas/gallery-photo-schema.ts
Admins can now upload photos to /galeria via presigned S3 PUT URLs.
Each photo stores title, date and location in a new GalleryPhoto table
and is displayed in the lightbox. Admins can also edit and soft-delete
photos through per-card controls; regular users see a read-only gallery.
Add local migration files for add_zero_to_agent and add_flyer_images_carousel
which were applied to the DB but missing from this branch's history.
@agustin-sanc
agustin-sanc force-pushed the feature/gallery-admin-uploads branch from 58d04c5 to 076ff65 Compare June 8, 2026 03:55
The UI treats takenAt as a date-only value (input type="date", substring(0,10)),
but the DB column was TIMESTAMP(3), risking timezone-driven date drift. Switch
the Prisma field to @db.Date and update the migration accordingly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant