Express and Prisma backend for Pixel Vault. It handles user authentication, profile management, image metadata, Cloudinary uploads, Swagger docs, and PostgreSQL persistence.
- Node.js 20
- Express 4
- Prisma 7 with PostgreSQL
- Cloudinary for file storage
- JWT authentication
- Swagger UI at
/api-docs - Docker and Render deployment support
- Email/password registration and login
- JWT-protected routes
- User profile fetch and update
- Password change endpoint
- Cloudinary upload flow for image files
- Image metadata save, update, delete, and search
- Health checks for app, database, and Cloudinary
src/
controllers/ Request handlers
middleware/ Auth middleware
models/ Prisma-backed data access
routes/ Express route definitions
utils/ Errors, logging, async helpers
index.js App bootstrap
prisma.js Prisma client setup
swagger.js OpenAPI configuration
prisma/
schema.prisma
migrations/
Dockerfile
docker-compose.yml
render.yaml
Copy .env.example to .env and set all required values.
| Variable | Required | Description |
|---|---|---|
PORT |
Yes | API port. Default local setup uses 5000. |
NODE_ENV |
Yes | development or production. |
POSTGRES_USER |
Docker only | Local Postgres username for Compose. |
POSTGRES_PASSWORD |
Docker only | Local Postgres password for Compose. |
POSTGRES_DB |
Docker only | Local Postgres database name for Compose. |
POSTGRES_PORT |
Docker only | Host port mapped to Postgres. |
DATABASE_URL |
Yes | PostgreSQL connection string used by Prisma. |
JWT_SECRET |
Yes | Secret used to sign JWTs. |
CLOUDINARY_CLOUD_NAME |
Yes | Cloudinary cloud name. |
CLOUDINARY_API_KEY |
Yes | Cloudinary API key. |
CLOUDINARY_API_SECRET |
Yes | Cloudinary API secret. |
The app validates these at startup and exits with a clear error if any required value is missing.
- Install dependencies:
npm install- Create
.env:
cp .env.example .env- Start the stack:
docker-compose up --buildThis starts:
- PostgreSQL on
localhost:${POSTGRES_PORT} - API on
http://localhost:5000
The app container runs prisma migrate deploy before starting the server.
- Start a PostgreSQL instance and create a database.
- Set
DATABASE_URLin.env. - Install dependencies:
npm install- Apply migrations:
npx prisma migrate deploy- Start the API:
npm run devThe frontend application that integrates with this backend service can be found in the frontend repository
npm run dev
npm start
npx prisma migrate deploy
docker-compose up --build
docker-compose downLocal default base URL:
http://localhost:5000
Interactive API docs:
http://localhost:5000/api-docs
GET /healthLightweight container health check.GET /api/healthDetailed health status for service, database, and Cloudinary.
POST /api/user/register
Request:
{
"firstName": "Jonh",
"lastName": "Doe",
"gender": "MALE",
"email": "jonh@example.com",
"password": "strong-password"
}Response:
{
"user": {
"id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"email": "jonh@example.com",
"firstName": "Jonh",
"lastName": "Doe",
"gender": "MALE",
"created_at": "2026-06-23T09:20:10.000Z"
}
}If this email had pending group invites created before registration, those invites are automatically linked to the new account.
POST /api/user/login
Request:
{
"email": "jonh@example.com",
"password": "strong-password"
}Response:
{
"token": "jwt-token",
"user": {
"id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"email": "jonh@example.com"
}
}GET /api/user/profileRequiresAuthorization: Bearer <token>.
Response:
{
"user": {
"id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"email": "jonh@example.com",
"firstName": "Jonh",
"lastName": "Doe",
"gender": "MALE",
"createdAt": "2026-06-23T09:20:10.000Z",
"uploadCount": 12
}
}PUT /api/user/profile
Request:
{
"firstName": "Jonh",
"lastName": "Doe",
"gender": "MALE"
}Response:
{
"user": {
"id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"email": "jonh@example.com",
"firstName": "Jonh",
"lastName": "Doe",
"gender": "MALE",
"createdAt": "2026-06-23T09:20:10.000Z"
}
}PUT /api/user/change-password
Request:
{
"currentPassword": "old-password",
"newPassword": "new-password"
}Response:
{
"message": "Password changed successfully"
}GET /api/user/search?email=aj&limit=10RequiresAuthorization: Bearer <token>. Returns up to 20 matching users for invite/autocomplete flows and excludes the authenticated user. Theemailquery must be at least 2 characters. This endpoint is rate-limited.
Response:
{
"users": [
{
"id": "5d8471f6-1c44-45ef-9c4e-ec44d95cb635",
"email": "jonh@example.com",
"firstName": "Jonh",
"lastName": "Doe"
}
]
}All share-group routes require Authorization: Bearer <token>.
POST /api/share-groups
Request:
{
"name": "friends"
}Response:
{
"group": {
"id": "5f68d7b0-4d3d-4eb4-a43b-a4484e67cfcc",
"name": "friends",
"ownerUserId": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"isOwner": true,
"createdAt": "2026-06-23T10:00:00.000Z",
"updatedAt": "2026-06-23T10:00:00.000Z",
"owner": {
"id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"email": "jonh@example.com",
"firstName": "Jonh",
"lastName": "Doe"
},
"imageCount": 0,
"memberCount": 0,
"inviteCounts": {
"pending": 0,
"accepted": 0,
"rejected": 0,
"removed": 0
},
"members": []
}
}Group names are limited to 10 characters, can contain only letters, numbers, underscores, and hyphens, and are unique per owner.
GET /api/share-groups/my-ownedGET /api/share-groups/my-joinedGET /api/share-groups/:id
These routes return the same group structure shown above, wrapped as either { "groups": [...] } or { "group": { ... } }.
GET /api/share-groups/my-invitesUse?status=pendingto return only pending invites.
Response:
{
"invites": [
{
"id": "member-id",
"email": "friend@example.com",
"status": "PENDING",
"invitedAt": "2026-06-23T10:30:00.000Z",
"respondedAt": null,
"group": {
"id": "5f68d7b0-4d3d-4eb4-a43b-a4484e67cfcc",
"name": "friends",
"ownerUserId": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"owner": {
"id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"email": "jonh@example.com",
"firstName": "Jonh",
"lastName": "Doe"
}
},
"user": {
"id": "user-1",
"email": "friend@example.com",
"firstName": "Friend",
"lastName": "One"
}
}
]
}POST /api/share-groups/:id/invite
Request:
{
"emails": ["friend1@example.com", "friend2@example.com"]
}Response:
{
"group": {
"id": "5f68d7b0-4d3d-4eb4-a43b-a4484e67cfcc",
"name": "friends",
"ownerUserId": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"isOwner": true,
"createdAt": "2026-06-23T10:00:00.000Z",
"updatedAt": "2026-06-23T10:45:00.000Z",
"owner": {
"id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"email": "jonh@example.com",
"firstName": "Jonh",
"lastName": "Doe"
},
"imageCount": 0,
"memberCount": 2,
"inviteCounts": {
"pending": 2,
"accepted": 0,
"rejected": 0,
"removed": 0
},
"members": [
{
"id": "member-1",
"email": "friend1@example.com",
"status": "PENDING",
"invitedAt": "2026-06-23T10:45:00.000Z",
"respondedAt": null,
"user": {
"id": "user-1",
"firstName": "Friend",
"lastName": "One",
"email": "friend1@example.com"
}
}
]
}
}POST /api/share-groups/invites/:memberId/acceptPOST /api/share-groups/invites/:memberId/reject
Response:
{
"invite": {
"id": "member-id",
"email": "friend@example.com",
"status": "ACCEPTED",
"invitedAt": "2026-06-23T10:30:00.000Z",
"respondedAt": "2026-06-23T10:35:00.000Z",
"group": {
"id": "5f68d7b0-4d3d-4eb4-a43b-a4484e67cfcc",
"name": "friends",
"ownerUserId": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"owner": {
"id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"email": "jonh@example.com",
"firstName": "Jonh",
"lastName": "Doe"
}
},
"user": {
"id": "user-1",
"email": "friend@example.com",
"firstName": "Friend",
"lastName": "One"
}
}
}GET /api/share-groups/:id/images?searchText=sun&keyword=sunset&visibility=all&uploaderUserId=user-id&fromDate=2026-06-01&toDate=2026-06-30&sortBy=addedAt&sortOrder=desc&limit=20&offset=0Lists images shared in the group. Available to the owner and accepted members.searchText,keyword,uploaderUserId, and date filters affect the returned data and the counts.limitandoffsetaffect only the returned page.
Response:
{
"group": {
"id": "5f68d7b0-4d3d-4eb4-a43b-a4484e67cfcc",
"name": "friends"
},
"searchText": "sun",
"keyword": "sunset",
"visibility": "all",
"uploaderUserId": null,
"fromDate": null,
"toDate": null,
"sortBy": "addedAt",
"sortOrder": "desc",
"data": [
{
"id": "group-image-id",
"addedAt": "2026-06-23T11:00:00.000Z",
"addedBy": {
"id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"email": "jonh@example.com",
"firstName": "Jonh",
"lastName": "Doe"
},
"image": {
"id": "image-id",
"user_id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"title": "Sunset",
"description": "Beach sunset",
"image_url": "https://res.cloudinary.com/demo/image/upload/sample.jpg",
"keywords": ["sunset", "beach", "orange"],
"width": 1200,
"height": 800,
"size": 245000,
"is_private": true,
"uploaded_at": "2026-06-23T09:45:00.000Z"
}
}
],
"totalCount": 12,
"privateCount": 7,
"publicCount": 5,
"limit": 20,
"offset": 0
}POST /api/share-groups/:id/images/add
Request:
{
"imageIds": ["image-id-1", "image-id-2"]
}Response:
Returns the updated group object.
POST /api/share-groups/:id/images/remove
Request:
{
"imageIds": ["image-id-1", "image-id-2"]
}Response:
Returns the updated group object.
POST /api/share-groups/:id/images/:imageId/downloadRecords the download in the backend for audit purposes and returns the image download URL. Available to the owner and accepted members. This endpoint is rate-limited.
Response:
{
"group": {
"id": "5f68d7b0-4d3d-4eb4-a43b-a4484e67cfcc",
"name": "friends",
"owner_user_id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd"
},
"image": {
"id": "image-id",
"user_id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"title": "Sunset",
"description": "Beach sunset",
"image_url": "https://res.cloudinary.com/demo/image/upload/sample.jpg",
"keywords": ["sunset", "beach", "orange"],
"width": 1200,
"height": 800,
"size": 245000,
"is_private": true,
"uploaded_at": "2026-06-23T09:45:00.000Z"
},
"downloadUrl": "https://res.cloudinary.com/demo/image/upload/sample.jpg"
}GET /api/share-groups/:id/downloads/summary
Response:
{
"group": {
"id": "5f68d7b0-4d3d-4eb4-a43b-a4484e67cfcc",
"name": "friends"
},
"totalDownloads": 8,
"uniqueDownloaderCount": 3,
"uniqueDownloadedImageCount": 5
}GET /api/share-groups/:id/downloads?limit=20&offset=0
Response:
{
"group": {
"id": "5f68d7b0-4d3d-4eb4-a43b-a4484e67cfcc",
"name": "friends"
},
"data": [
{
"id": "download-id",
"downloadedAt": "2026-06-23T12:00:00.000Z",
"downloader": {
"id": "downloader-id",
"email": "friend@example.com",
"firstName": "Friend",
"lastName": "One"
},
"image": {
"id": "image-id",
"title": "Sunset",
"image_url": "https://res.cloudinary.com/demo/image/upload/sample.jpg",
"is_private": true
}
}
],
"totalCount": 8,
"limit": 20,
"offset": 0
}PUT /api/share-groups/:id
Request:
{
"name": "family"
}Response:
Returns the updated group object.
-
DELETE /api/share-groups/:idDeletes a group owned by the authenticated user. -
DELETE /api/share-groups/:id/members/:memberIdRemoves a member or invite from a group owned by the authenticated user and returns the updatedgroupobject.
All image routes require Authorization: Bearer <token>.
POST /api/image/minio-uploadmultipart/form-datawith file fieldimageorimages. You can upload up to 40 files in one request, and each image must be at most5 MB.
Response:
{
"secure_url": "https://res.cloudinary.com/demo/image/upload/v1/pixelvault/user-1/sunset.jpg",
"width": 1200,
"height": 800,
"size": 245000,
"originalName": "sunset.jpg",
"uploads": [
{
"secure_url": "https://res.cloudinary.com/demo/image/upload/v1/pixelvault/user-1/sunset.jpg",
"width": 1200,
"height": 800,
"size": 245000,
"originalName": "sunset.jpg"
}
]
}For multi-file uploads, the response is { "uploads": [...] }.
POST /api/image/save
{
"title": "Sunset",
"description": "Beach sunset",
"keywords": "sunset, beach, orange",
"imageUrl": "https://res.cloudinary.com/...",
"isPrivate": true
}Or for multiple images:
{
"title": "Sunset",
"description": "Beach sunset",
"keywords": "sunset, beach, orange",
"imageUrls": [
{
"imageUrl": "https://res.cloudinary.com/...",
"width": 1200,
"height": 800,
"size": 245000
},
{
"imageUrl": "https://res.cloudinary.com/...",
"width": 1000,
"height": 700,
"size": 185000
}
],
"isPrivate": true
}Each uploaded image is stored as a separate database record. title, description, keywords, and isPrivate are applied to every saved image in the request. The save endpoint accepts either imageUrl for one image or imageUrls for one or many images.
When using imageUrls, you can save up to 40 images and each image must be at most 5 MB.
POST /api/image/search
{
"searchText": "sunset",
"limit": 12,
"offset": 0,
"myLibrary": false
}Response counts are not affected by pagination. limit and offset only affect the data array. searchText affects both data and the counts.
Example response:
{
"data": [
{
"id": "image-id",
"user_id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"title": "Sunset",
"description": "Beach sunset",
"image_url": "https://res.cloudinary.com/demo/image/upload/sample.jpg",
"keywords": ["sunset", "beach", "orange"],
"width": 1200,
"height": 800,
"size": 245000,
"is_private": false,
"uploaded_at": "2026-06-23T09:45:00.000Z"
}
],
"totalCount": 25,
"privateCount": 0,
"publicCount": 25
}myLibrary: true restricts results and counts to the authenticated user's uploads. Otherwise the route returns and counts public images only, so privateCount will be 0.
POST /api/image/bulk/privacy
{
"imageIds": ["uuid-1", "uuid-2"],
"isPrivate": true
}Updates privacy for up to 100 owned images in one request.
Response:
{
"message": "Image privacy updated successfully",
"updatedCount": 2,
"isPrivate": true
}POST /api/image/bulk/delete
{
"imageIds": ["uuid-1", "uuid-2"]
}Deletes up to 100 owned images in one request. Cloudinary deletion is attempted before the database records are removed.
Response:
{
"message": "Images deleted successfully",
"deletedCount": 2
}PUT /api/image/:id
{
"title": "Updated title",
"description": "Updated description",
"keywords": "tag1, tag2",
"isPrivate": false
}Response:
{
"image": {
"id": "image-id",
"user_id": "7e55f5fa-0b33-48e6-8d61-66c7f17ad9cd",
"title": "Updated title",
"description": "Updated description",
"image_url": "https://res.cloudinary.com/demo/image/upload/sample.jpg",
"keywords": ["tag1", "tag2"],
"width": 1200,
"height": 800,
"size": 245000,
"is_private": false,
"uploaded_at": "2026-06-23T09:45:00.000Z"
}
}DELETE /api/image/:id
Deletes the database record and attempts to remove the Cloudinary asset.
Prisma schema lives in prisma/schema.prisma.
Current models:
usersid,email,password_hashfirstName,lastName,gendercreated_at
imagesid,user_id,title,descriptionimage_url,keywordswidth,height,sizeis_private,uploaded_at
Dockerfile builds a production image, generates the Prisma client, runs migrations, and starts the server on port 5000.
render.yaml defines:
- One web service using the Docker runtime
- One PostgreSQL database
- Required environment variables for production
- Uploads are stored in Cloudinary under
pixelvault/<userId>. - Startup fails fast if required environment variables are missing.
- Request and error logging is enabled through the shared logger.
For issues and questions, create an issue in the repository.