feat(galeria): admin photo upload with S3 and DB persistence - #257
feat(galeria): admin photo upload with S3 and DB persistence#257agustin-sanc wants to merge 9 commits into
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
WalkthroughThis 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
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
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
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 bydeletedAt: nulland sorts bytakenAt; 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+
.jpgconstruction is duplicated insrc/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 todownloadImageinsrc/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 PrismaRoleenum over a loosestring.
GalleryUser.role: stringweakens thecurrentUser?.role === 'ADMIN'check (typos compile silently).getCurrentSession()returns a Prisma User withrole: Roleenum, and the type is lost when assigned toGalleryUser. Since@prisma/clientis 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
📒 Files selected for processing (14)
prisma/migrations/20260417000000_add_gallery_photo/migration.sqlprisma/schema.prismasrc/actions/gallery/create-gallery-photo.tssrc/actions/gallery/delete-gallery-photo.tssrc/actions/gallery/get-gallery-photo.tssrc/actions/gallery/get-gallery-photos.tssrc/actions/gallery/update-gallery-photo.tssrc/app/(platform)/galeria/page.tsxsrc/components/photo-gallery/delete-photo-dialog.tsxsrc/components/photo-gallery/gallery-photo-form-dialog.tsxsrc/components/photo-gallery/gallery.tsxsrc/components/photo-gallery/photo-card.tsxsrc/components/photo-gallery/photo-dialog.tsxsrc/schemas/gallery-photo-schema.ts
| model GalleryPhoto { | ||
| id String @id @default(cuid()) | ||
| title String | ||
| takenAt DateTime |
There was a problem hiding this comment.
🧩 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)' prismaRepository: 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 2Repository: 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.sqlRepository: 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 2Repository: 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.
| 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.
| const session = await prisma.session.findUnique({ | ||
| where: { id: sessionId }, | ||
| include: { user: true }, | ||
| }); | ||
|
|
||
| if (!session || session.user.role !== 'ADMIN') { |
There was a problem hiding this comment.
🧩 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.prismaRepository: 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.tsRepository: programaconnosotros/pcn-website
Length of output: 999
🏁 Script executed:
cat -n src/actions/gallery/update-gallery-photo.tsRepository: programaconnosotros/pcn-website
Length of output: 1389
🏁 Script executed:
cat -n src/actions/gallery/create-gallery-photo.tsRepository: 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.
| await prisma.galleryPhoto.update({ | ||
| where: { id }, | ||
| data: { deletedAt: new Date() }, | ||
| }); |
There was a problem hiding this comment.
🧩 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/galleryRepository: programaconnosotros/pcn-website
Length of output: 2659
🏁 Script executed:
cat -n src/actions/gallery/delete-gallery-photo.tsRepository: 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 -B2Repository: 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 -B1Repository: 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.
| const session = await prisma.session.findUnique({ | ||
| where: { id: sessionId }, | ||
| include: { user: true }, | ||
| }); | ||
|
|
||
| if (!session || session.user.role !== 'ADMIN') { |
There was a problem hiding this comment.
🧩 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.prismaRepository: 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 -20Repository: 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 2Repository: 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.
| 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.
| const photo = await prisma.galleryPhoto.update({ | ||
| where: { id: validatedData.id }, | ||
| data: { | ||
| title: validatedData.title, | ||
| location: validatedData.location, | ||
| takenAt: validatedData.takenAt, | ||
| imageUrl: validatedData.imageUrl, | ||
| }, | ||
| }); |
There was a problem hiding this comment.
🧩 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/galleryRepository: 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.
| 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]); |
There was a problem hiding this comment.
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.
| 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.
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.
58d04c5 to
076ff65
Compare
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.
…story" This reverts commit 9c658cb.
Summary
GalleryPhotomodel withtitle,takenAt(date-only),location,imageUrl, and soft-delete supportfolder: 'gallery', reusing the existingFileUploadcomponent andgetPresignedUrlaction)/galeriaconverted from a client-only page with a hardcoded array to a server component that fetches from the DB; share URLs (?foto=<cuid>) still workTest plan
ADMIN, navigate to/galeria— "Subir foto" button is visible?foto=<id>in a new tab — correct photo loadsdeletedAtset in DBREGULARuser — no upload/edit/delete controls visible; direct server action call throws "No tienes permisos"pnpm lint— no errorsSummary by CodeRabbit
Release Notes