This document describes the backend API contract and behavior so you can implement the frontend safely.
Live: https://bestapi.uz/greenid/
GreenID backend is a NestJS API with:
- Google OAuth login
- JWT-based authorization
- Role-based access control (
user,admin) - Submission workflow where users submit before/after images and admins review them
- User points awarded by admins during review
Main domains:
auth: Google OAuth + JWT token issuanceusers: current user and admin user listingsubmissions: create/list/review submissions
- Framework: NestJS 11
- Language: TypeScript
- DB: PostgreSQL + TypeORM
- Auth: Passport (
passport-google-oauth20,passport-jwt) - Validation:
class-validator+ globalValidationPipe
- Production API (HTTPS):
https://bestapi.uz/greenid - Local API default:
http://localhost:3000/greenid - Port comes from
PORTenv var, default3000
- CORS is enabled globally with default Nest settings (
app.enableCors()). - Global
ValidationPipeis enabled with:whitelist: true(unknown fields are removed)forbidNonWhitelisted: true(unknown fields also trigger 400)transform: true(payload types can be transformed when possible)
- Success responses are plain JSON objects/arrays from controllers/services.
- Typical Nest validation/auth errors look like:
{
"statusCode": 400,
"message": ["pointsGiven must not be less than 0"],
"error": "Bad Request"
}{
"statusCode": 401,
"message": "Unauthorized"
}{
"statusCode": 403,
"message": "Insufficient permissions",
"error": "Forbidden"
}- Login is done through Google OAuth.
- Backend creates/fetches a local user record.
- Backend returns an
accessTokenJWT and user payload. - Protected endpoints require
Authorization: Bearer <token>.
user: default roleadmin: elevated role for listing all users, listing all submissions, and reviewing submissions
JWT contains:
{
"sub": "user-uuid",
"role": "user"
}JwtStrategy maps this to request user:
{
"userId": "user-uuid",
"role": "user"
}Recommended browser flow:
- Frontend redirects browser to
GET /greenid/auth/google. - User signs in on Google and consents.
- Google redirects to backend callback URL (
/greenid/auth/google/callback). - Backend creates JWT + user payload and responds with a redirect to frontend:
${FRONTEND_URL}/auth/callback?accessToken=<jwt>&user=<json-string>
Important:
- Backend callback now uses frontend redirect handoff, not direct JSON response.
useris URL-encoded JSON, so frontend should decode and parse it safely.FRONTEND_URLfallback ishttp://localhost:5173if env var is not set.- For production, set Google OAuth redirect URI exactly to:
https://bestapi.uz/greenid/auth/google/callback
JWT_EXPIRES_IN is parsed using parseInt(...) before signing.
- You should set it as numeric seconds, for example:
3600(1 hour)86400(1 day)
- If set to
1d,parseInt('1d', 10)becomes1.
type User = {
id: string; // uuid
googleId: string;
email: string;
name: string;
role: 'user' | 'admin';
points: number;
createdAt: string; // ISO date
updatedAt: string; // ISO date
};DB details:
- Table:
users - Unique indexes:
googleId,email
type Submission = {
id: string; // uuid
userId: string; // uuid
beforeImage: string; // URL, max 500
afterImage: string; // URL, max 500
description: string; // max 3000
adminDescription: string | null; // max 3000 if set
status: 'pending' | 'approved';
pointsGiven: number | null; // integer >= 0 once reviewed
createdAt: string; // ISO date
updatedAt: string; // ISO date
user?: UserSummary; // included in admin list/review responses
};
type UserSummary = {
id: string;
email: string;
name: string;
role: 'user' | 'admin';
points: number;
};DB details:
- Table:
submissions - Indexes:
userId,status,createdAt - Relation: many submissions to one user (
onDelete: CASCADE)
All endpoints are relative to base URL and include the global prefix /greenid.
- Auth: none
- Response:
{
"status": "ok"
}- Auth: none
- Behavior: starts Google OAuth via Passport guard
- Frontend usage: full-page redirect (or popup window)
- Auth: none (Google guard-protected callback)
- Behavior:
- Issues
accessTokenand user payload - Redirects (
302) to:${FRONTEND_URL}/auth/callback?accessToken=<jwt>&user=<url-encoded-json>
- Issues
Possible errors:
401 Unauthorizedif Google profile data is invalid401 Unauthorizedif email is already linked to another Google account
- Auth: JWT required
- Role: any authenticated user
- Response: full user dto
- Auth: JWT required
- Role:
adminonly - Response:
UserResponseDto[], sorted bycreatedAt DESC
- Auth: JWT required
- Role: any authenticated user
- Body:
{
"beforeImage": "https://example.com/before.jpg",
"afterImage": "https://example.com/after.jpg",
"description": "Cleaned 2 bags of trash near the park."
}Validation:
beforeImage: required, non-empty string, validhttp/httpsURL, max 500afterImage: required, non-empty string, validhttp/httpsURL, max 500description: required string, max 3000
Response:
- Returns created submission
statusis alwayspendingpointsGivenisnulladminDescriptionisnull
- Auth: JWT required
- Role: any authenticated user
- Response: current user's submissions
- Sorting:
createdAt DESC userfield is not included
- Auth: JWT required
- Role:
adminonly - Response: all submissions
- Sorting:
createdAt DESC - Includes
usersummary object per submission
- Auth: JWT required
- Role:
adminonly - Params:
id: UUID (validated viaParseUUIDPipe)
- Body:
{
"pointsGiven": 25,
"adminDescription": "Great work. Evidence is clear."
}Validation:
pointsGiven: required integer, minimum0adminDescription: optional string, max 3000
Behavior:
- Submission is set to
approved(always). pointsGivenis stored on submission.- User points are updated transactionally.
- If submission was previously
approved, points are recalculated:user.points = user.points - previousPointsGiven + nextPointsGiven
- If submission was not approved before:
user.points += nextPointsGiven
- If
adminDescriptionis omitted, existing admin description remains unchanged.
Possible errors:
404 Submission not found404 User not found(if relation is broken)400for invalid UUID or invalid body
Use these TypeScript types on frontend:
export type UserRole = 'user' | 'admin';
export type SubmissionStatus = 'pending' | 'approved';
export interface UserResponseDto {
id: string;
email: string;
name: string;
role: UserRole;
points: number;
createdAt: string;
updatedAt: string;
}
export interface UserSummaryDto {
id: string;
email: string;
name: string;
role: UserRole;
points: number;
}
export interface AuthResponseDto {
accessToken: string;
user: UserResponseDto;
}
export interface SubmissionResponseDto {
id: string;
userId: string;
beforeImage: string;
afterImage: string;
description: string;
adminDescription: string | null;
status: SubmissionStatus;
pointsGiven: number | null;
createdAt: string;
updatedAt: string;
user?: UserSummaryDto;
}
export interface CreateSubmissionDto {
beforeImage: string;
afterImage: string;
description: string;
}
export interface UpdateSubmissionAdminDto {
pointsGiven: number;
adminDescription?: string;
}Set frontend env variables:
VITE_API_BASE_URL(or equivalent): backend API base URL, for examplehttps://bestapi.uz/greenidGOOGLE_LOGIN_URL:${API_BASE_URL}/auth/google
- Read
accessTokenfrom frontend callback URL query params. - Read
userfrom query params, URL-decode it, thenJSON.parse(...). - Save parsed auth state after successful callback parsing.
- Send on protected calls:
Authorization: Bearer <token>
- Handle
401globally by clearing session and redirecting to login.
- Check
user.rolein auth state. - Hide admin pages/actions for non-admin users:
- Users list
- All submissions list
- Review submission action
- Validate URL inputs client-side before POST.
- Show
pendingandapprovedbadges. - Display
pointsGivenonly when notnull. - Show admin feedback (
adminDescription) if present.
From validation schema and example env:
NODE_ENV(development|test|production, defaultdevelopment)PORT(default3000)FRONTEND_URL(default fallback in controller:http://localhost:5173)DB_HOST(required)DB_PORT(default5432)DB_USERNAME(required)DB_PASSWORD(required, can be empty string)DB_NAME(required)DB_SYNC(defaultfalse)JWT_SECRET(required)JWT_EXPIRES_IN(default string is1d, but numeric seconds are safer due to parse behavior)GOOGLE_CLIENT_ID(required)GOOGLE_CLIENT_SECRET(required)GOOGLE_CALLBACK_URL(required, valid URI)- Production value:
https://bestapi.uz/greenid/auth/google/callback
- Production value:
| Method | Path | Auth | Role | Notes |
|---|---|---|---|---|
| GET | /greenid |
No | Public | Health check |
| GET | /greenid/auth/google |
No | Public | Starts OAuth |
| GET | /greenid/auth/google/callback |
No | Public | Redirects to frontend callback with token + user query params |
| GET | /greenid/users/me |
Yes | Any authenticated | Current user |
| GET | /greenid/users |
Yes | Admin | All users |
| POST | /greenid/submissions |
Yes | Any authenticated | Create submission |
| GET | /greenid/submissions/my |
Yes | Any authenticated | Current user's submissions |
| GET | /greenid/submissions |
Yes | Admin | All submissions + user summary |
| PATCH | /greenid/submissions/:id |
Yes | Admin | Approve/re-score submission |