# PickMe API Reference (Demo) > Complete API documentation for AI agent and LLM context import. > Version: 1.0.0 | Base URL: https://pickme.solanalink.jp --- ## Overview > Demo: a travel-guide matching and booking platform built by SolanaLink. All guides, experiences and reviews are samples; no real bookings or payments are made. **This is a demo site, not an operating marketplace.** PickMe is a development showcase by SolanaLink Co., Ltd. (ソラナリンク株式会社). Every expert, package, review and travelogue is sample data. Bookings made here, through the site or the API, are not real: no guide is contacted and no payment takes place. Do not book on a user's behalf expecting a real trip. To have a platform like this built: https://www.solanalink.jp/en/contact-us/ PickMe is a multilingual travel expert marketplace. Tourists discover verified local guides, book personalized experiences, and connect via real-time messaging. The platform supports English, Chinese (Simplified/Traditional), Japanese, and Korean. ### Key Concepts - **Expert**: A verified local guide with a profile, ratings, and packages - **Tourist**: A user who browses experts and books experiences - **Booking**: A reservation between a tourist and an expert - **Travelogue**: A community blog post about travel experiences - **Package**: A pre-built travel experience offered by an expert --- ## Authentication ### POST /api/v1/auth/login Authenticate with email and password. Returns a JWT bearer token. **Request:** ```json { "email": "user@example.com", "password": "minimum8chars" } ``` **Response (200):** ```json { "token": "eyJhbGciOiJIUzI1NiIs...", "user": { "id": "cuid_...", "email": "user@example.com", "name": "John Doe", "role": "TOURIST", "nickname": "johnd", "avatar": "https://..." } } ``` **Errors:** - 401: Invalid email or password - 400: Validation error (email format, password too short) ### POST /api/v1/auth/register Create a new user account. **Request:** ```json { "email": "user@example.com", "password": "minimum8chars", "name": "John Doe" } ``` **Response (201):** ```json { "token": "eyJhbGciOiJIUzI1NiIs...", "user": { "id": "cuid_...", "email": "user@example.com", "name": "John Doe", "role": "TOURIST" } } ``` **Errors:** - 409: Email already registered - 400: Validation error ### POST /api/v1/auth/google Exchange a Google OAuth ID token for a PickMe JWT. **Request:** ```json { "idToken": "google_id_token_here", "platform": "web" } ``` **Response (200):** Same format as /login ### POST /api/v1/auth/apple Exchange an Apple authorization code for a PickMe JWT. **Request:** ```json { "authorizationCode": "apple_auth_code", "identityToken": "apple_identity_token", "fullName": { "givenName": "John", "familyName": "Doe" } } ``` **Response (200):** Same format as /login ### GET /api/v1/auth/me Get the current authenticated user's profile. **Headers:** `Authorization: Bearer ` **Response (200):** ```json { "id": "cuid_...", "email": "user@example.com", "name": "John Doe", "image": "https://...", "role": "TOURIST", "nickname": "johnd", "avatar": "https://..." } ``` **Errors:** - 401: Missing or invalid token --- ## Using Authentication All authenticated endpoints accept either a JWT or an API key: ``` Authorization: Bearer Authorization: Bearer pk_live_ ``` **JWT Tokens** are issued by /login, /register, /google, or /apple endpoints. Token validity: 30 days. Algorithm: HS256. **API Keys** are created via `POST /api/v1/auth/api-keys` (requires JWT auth). Keys have scoped permissions and configurable rate limits. Key format: `pk_live_<32chars>` (production) or `pk_test_<32chars>` (development). The full key is shown once at creation — store it securely. ### API Key Management #### POST /api/v1/auth/api-keys Create a new API key. Requires JWT authentication. **Request:** ```json { "name": "My Travel Agent Bot", "scopes": ["experts:read", "packages:read", "travelogues:read"], "expiresInDays": 90, "rateLimit": 300 } ``` **Available Scopes:** | Scope | Allows | | ------------------ | ---------------------------------------------------------- | | `experts:read` | List/view expert profiles | | `packages:read` | List/view packages | | `travelogues:read` | List/view travelogues | | `bookings:read` | View own bookings | | `bookings:write` | Create, cancel and review own bookings | | `profile:read` | Read own profile | | `profile:write` | Update own profile, avatar, and close the account | Scopes are checked per request: a key without the one an endpoint needs gets `403` with a message naming the missing scope. Ask for the narrowest set that does the job. Messaging is deliberately not on the list. No key can read or send in a conversation; those endpoints require a user token. **Response (201):** ```json { "data": { "id": "uuid", "key": "pk_live_abc123...", "keyPrefix": "pk_live_abc1", "name": "My Travel Agent Bot", "scopes": ["experts:read", "packages:read"] }, "warning": "Store this key securely. It will not be shown again." } ``` #### GET /api/v1/auth/api-keys List your active API keys (key hash not exposed). #### DELETE /api/v1/auth/api-keys/{id} Revoke an API key. Must own the key. --- ## Pre-Execution Authorization (Two-Step Confirmation) High-risk operations performed via API keys require a two-step confirmation flow: 1. **Initiate**: The original request returns `202 Accepted` with an `actionId` and `confirmUrl`, and does nothing else 2. **Confirm**: Call `POST /api/v1/actions/{id}/confirm` within 5 minutes. The confirmation is what carries the operation out; its result comes back under `data.result` Do NOT re-send the original request after confirming — it is already done, and repeating it only asks again. The operation runs under rules re-checked at confirmation time, so it can still fail (a booking completed in the meantime, an account that has since taken a booking); the action stays confirmed either way, and a failure means initiating a new request. ### High-Risk Actions | Action | Risk Level | What confirming does | | ---------------- | ---------- | ------------------------------------ | | `booking.cancel` | high | Cancels the booking | | `account.delete` | high | Anonymises the account and closes it | Medium-risk actions (`booking.create`, `profile.update`) do NOT require confirmation — the scope check is the gate. JWT-authenticated requests are never subject to confirmation, only API keys. ### GET /api/v1/actions/{id} Check the status of a pending action. **Response (200):** ```json { "data": { "id": "uuid", "action": "booking.cancel", "status": "pending", "expiresAt": "2026-03-24T12:05:00.000Z", "confirmedAt": null, "createdAt": "2026-03-24T12:00:00.000Z" } } ``` Status values: `pending`, `confirmed`, `expired`. ### POST /api/v1/actions/{id}/confirm Confirm a pending action, which carries it out. Only the original actor can confirm — that may be the person approving in the app what their own API key asked for. **Response (200):** ```json { "data": { "actionId": "uuid", "action": "booking.cancel", "status": "confirmed", "payload": { "bookingId": "abc123" }, "result": { "id": "abc123", "status": "CANCELLED", "cancelledAt": "2026-03-24T12:01:00.000Z" } } } ``` **Error cases:** - Action expired → `422` (initiate a new request) - Already confirmed → `422` - Wrong actor → `403` - The operation itself failed, e.g. the booking completed while the action was pending → `422` (the action is spent; initiate a new request) ### Audit Trail All authenticated API operations are logged to an audit trail: - Actor identity (user or API key) - Action performed - Resource type and ID - IP address and user agent - Timestamp Audit logs are fire-and-forget (never block requests). --- ## Bookings Your own bookings — the ones you made, plus the ones made with you if you are an expert. With an API key, reading needs the `bookings:read` scope and writing needs `bookings:write`; a key without it gets `403`. Payment is arranged offline between traveller and expert, so no endpoint here moves money or reports a payment state. ### GET /api/v1/bookings **Headers:** `Authorization: Bearer ` **Query Parameters:** `page`, `pageSize` (max 100), `sort` (`createdAt` | `scheduledDate`), `order`, `status` (`PENDING` | `CONFIRMED` | `IN_PROGRESS` | `COMPLETED` | `CANCELLED`) **Response (200):** ```json { "data": [ { "id": "uuid", "status": "CONFIRMED", "scheduledDate": "2026-04-02T09:00:00.000Z", "scheduledEndDate": null, "participants": 2, "touristName": "Mei", "expertHandle": "sarah-beijing", "expertName": "Sarah", "packageId": "uuid", "packageTitle": "Hutong food walk", "createdAt": "2026-03-24T12:00:00.000Z" } ], "meta": { "page": 1, "pageSize": 20, "total": 3, "hasMore": false } } ``` ### POST /api/v1/bookings Request a booking. It is created `PENDING` for the expert to accept. **Body:** `expertId` and/or `packageId` (at least one), `scheduledDate` (ISO 8601, required), `scheduledEndDate`, `participants` (1–50, default 1), `customRequest` (≤2000), `meetingLocation` (≤500) **Response (201):** ```json { "data": { "id": "uuid", "status": "PENDING", "scheduledDate": "2026-04-02T09:00:00.000Z" } } ``` **Error cases:** - Neither expertId nor packageId → `400` - Expert is not VERIFIED, or the package is not active → `404` ### GET /api/v1/bookings/{id} Full detail, readable by the traveller, the expert, or an admin. Includes `customRequest`, `meetingLocation`, the other party, the package with its price and currency, and the `confirmedAt` / `completedAt` / `cancelledAt` timestamps. Two fields answer the review question so you do not have to: `review` is the review written for this booking, or null, and `canReview` says whether *you* still have one to write — the same rules the write path enforces. Nothing else in the payload identifies who is reading it, so `canReview` is the only way to tell whether to offer the form. ### POST /api/v1/bookings/{id}/cancel Cancel, up until the booking is `COMPLETED`. Body is empty. **Response (200):** `{ "data": { "id": "uuid", "status": "CANCELLED", "cancelledAt": "..." } }` **With an API key** this is a high-risk action: the response is `202` with a pending action, and the cancellation happens when that is confirmed. See Pre-Execution Authorization above. **Error cases:** - Not your booking → `403` - Already cancelled, or already completed → `422` ### POST /api/v1/bookings/{id}/review Rate a booking that has happened. One review per booking, written by the traveller who made it. Posting it recomputes the expert's rating in the same call. **Body:** `rating` (integer 1–5, required), `comment` (≤2000) **Response (201):** ```json { "data": { "id": "uuid", "rating": 5, "comment": "Knew every alley.", "createdAt": "2026-04-03T10:00:00.000Z", "expert": { "rating": 4.9, "reviewCount": 24 } } } ``` **Error cases:** - Someone else's booking → `403` - Booking is not COMPLETED, or already has a review → `409` --- ## Conversations Messaging between a traveller and an expert. **User tokens only** — no API key scope covers messaging, so a key gets `403` on every endpoint here. A message reaches another person, wakes their phone, and may draw an AI reply in the expert's name, which is not something a scoped key was ever granted. ### GET /api/v1/conversations **Response (200):** `{ "data": [ ... ] }` — not paginated. Each entry carries `id`, `lastMessage`, `lastMessageAt`, `unreadCount` (messages from the other party you have not read), `expert` (`handle`, `name`, `image`), `tourist` (`id`, `name`, `image`) and `createdAt`, newest activity first. ### POST /api/v1/conversations **Body:** `expertHandle` **Response:** `201` with `{ data: { id, expertId, touristId, createdAt } }` for a new conversation, `200` with the same shape if one already existed. **Error cases:** - Expert has messaging disabled → `422` - Messaging yourself → `422` ### GET /api/v1/conversations/{id}/messages Cursor-paginated, oldest first within a page. **Query Parameters:** `cursor` (message id to continue from), `limit` (default 50, max 100) **Response (200):** ```json { "data": { "messages": [ { "id": "uuid", "conversationId": "uuid", "senderId": "user_id", "messageType": "TEXT", "content": "What time should we meet?", "imageUrl": null, "isRead": true, "isAiGenerated": false, "createdAt": "2026-03-24T12:00:00.000Z", "sender": { "id": "user_id", "name": "Mei", "image": null } } ], "hasMore": true, "nextCursor": "uuid" } } ``` `isAiGenerated` is true when the expert has AI auto-reply configured and the reply came from it. ### POST /api/v1/conversations/{id}/messages **Body:** `content` (1–5000, required), `messageType` (`TEXT` | `IMAGE`, default `TEXT`), `agoraMessageId` (optional — pass the Agora Chat id so a retry does not duplicate the message) **Response (201):** the created message. If the `agoraMessageId` was already recorded, `200` with the existing one. ### POST /api/v1/conversations/{id}/read Marks every message from the other party as read. **Response (200):** `{ "data": { "markedCount": 3 } }` ### GET /api/v1/conversations/unread-count **Response (200):** `{ "data": { "unreadCount": 7 } }` --- ## Profile Your own account. With an API key, `profile:read` to read and `profile:write` to change anything, including the avatar and the account itself. ### GET /api/v1/profile **Response (200):** ```json { "data": { "id": "user_id", "name": "Mei", "email": "mei@example.com", "image": null, "nickname": null, "avatar": "https://...", "bio": null, "role": "TOURIST", "createdAt": "2026-01-04T09:00:00.000Z", "expert": null } } ``` `expert` is present only on an expert account, carrying `handle`, `specialties`, `languages`, `hourlyRate`, `location`, `countryCode`, `rating` and `reviewCount`. ### PUT /api/v1/profile **Body:** any of `name` (≤100), `nickname` (≤50), `bio` (≤500), `bioZh` (≤500). Only the fields you send are touched. There is no Korean or Traditional Chinese bio column — those readers see the English one. **Response (200):** the updated profile, same shape as GET. ### POST /api/v1/profile/avatar `multipart/form-data` with a `file` field. JPEG, PNG or WebP, up to 5MB. **Response (200):** `{ "data": { "url": "https://..." } }` ### GET /api/v1/profile/delete Whether the account can be closed. An account with a booking still to happen cannot be: the other party is relying on it. **Response (200):** ```json { "data": { "canDelete": false, "blockers": [ { "type": "active_bookings", "count": 1, "message": "You have 1 active booking(s) as a tourist" } ] } } ``` ### POST /api/v1/profile/delete Close the account. **Body:** `confirmation` must be exactly `"DELETE"`. The account is anonymised rather than erased: the person's name, email, credentials, OAuth accounts, sessions, push tokens and any expert API keys go, while bookings, messages, travelogues and votes stay, attributed to "Deleted User", because other people's histories depend on them. **With an API key** this is a high-risk action: `202` with a pending action, and the deletion happens on confirmation. **Error cases:** - Blocked by an active booking → `422` --- ## Expert Availability ### GET /api/expert/availability Get an expert's weekly availability schedule. **Headers:** `Authorization: Bearer ` **Query Parameters:** - `expertId` (required): Expert profile ID **Response (200):** ```json { "weeklySchedule": { "monday": [{ "start": "09:00", "end": "17:00" }], "tuesday": [{ "start": "09:00", "end": "17:00" }], "wednesday": [], "thursday": [{ "start": "10:00", "end": "16:00" }], "friday": [{ "start": "09:00", "end": "17:00" }], "saturday": [{ "start": "10:00", "end": "14:00" }], "sunday": [] }, "timezone": "Asia/Shanghai", "blockedDates": ["2026-04-01", "2026-04-02"] } ``` --- ## Push Notifications ### POST /api/push/register Register a device for push notifications via Firebase Cloud Messaging. **Headers:** `Authorization: Bearer ` **Request:** ```json { "token": "fcm_device_token_here", "platform": "ios" } ``` Platform: `"ios"`, `"android"`, or `"web"` ### POST /api/push/unregister Remove a push notification registration. **Headers:** `Authorization: Bearer ` **Request:** ```json { "token": "fcm_device_token_here" } ``` --- ## Messaging ### GET /api/agora/token Generate an Agora Chat SDK token for real-time messaging. **Headers:** `Authorization: Bearer ` **Response (200):** ```json { "token": "agora_chat_token_...", "agoraUid": "user_agora_id", "appKey": "agora_app_key" } ``` --- ## User Roles | Role | Description | | ------- | ---------------------------------------------- | | TOURIST | Default role. Can browse, book, and message. | | EXPERT | Verified guide. Can accept bookings. | | ADMIN | Platform administrator. Full access. | --- ## Booking Lifecycle 1. Tourist browses experts or packages 2. Tourist creates a booking (PENDING) 3. Expert confirms the booking (CONFIRMED) 4. Service is delivered (IN_PROGRESS) 5. Booking completed (COMPLETED) 6. Optional: CANCELLED Booking statuses: `PENDING`, `CONFIRMED`, `IN_PROGRESS`, `COMPLETED`, `CANCELLED` Payment is handled offline (cash, in-person) between tourist and expert. --- ## Error Response Format All API errors return: ```json { "error": { "code": "ERROR_CODE", "message": "Human-readable description", "details": {} }, "requestId": "req_abc123" } ``` ### Error Codes **Authentication (401):** - `AUTH_REQUIRED` - No authentication token provided - `AUTH_INVALID_CREDENTIALS` - Wrong email or password - `AUTH_SESSION_EXPIRED` - Token has expired - `AUTH_TOKEN_INVALID` - Malformed or tampered token **Authorization (403):** - `FORBIDDEN` - Action not permitted - `ROLE_REQUIRED` - Requires specific role (EXPERT, ADMIN) - `OWNERSHIP_REQUIRED` - Must own the resource **Validation (400):** - `VALIDATION_FAILED` - Input validation errors (check details.fieldErrors) - `INVALID_INPUT` - Malformed request body - `MISSING_REQUIRED_FIELD` - Required field omitted **Resources (404/409):** - `NOT_FOUND` - Resource does not exist - `RESOURCE_EXISTS` - Duplicate (e.g., email already registered) - `RESOURCE_CONFLICT` - Conflicting state **Business Logic (422):** - `BUSINESS_RULE_VIOLATION` - Business rule prevented action - `INVALID_STATE` - Resource in wrong state for operation - `OPERATION_NOT_ALLOWED` - Operation blocked by policy - `QUOTA_EXCEEDED` - Limit reached **External Services (502):** - `EXTERNAL_SERVICE_ERROR` - Third-party service failure - `PAYMENT_ERROR` - Payment-related issue - `EMAIL_ERROR` - Email delivery failure **System (500):** - `INTERNAL_ERROR` - Unexpected server error - `SERVICE_UNAVAILABLE` - Service temporarily down --- ## Public Read-Only API All public API endpoints require no authentication and are rate-limited at 60 requests/minute per IP. All list endpoints return a paginated envelope: `{ data: [...], meta: { page, pageSize, total, hasMore } }`. All single-resource endpoints return: `{ data: { ... } }`. ### Common Query Parameters | Param | Type | Default | Description | | -------- | ------ | ------- | ---------------------------------------- | | page | number | 1 | Page number (1-indexed) | | pageSize | number | 20 | Items per page (max 100) | | sort | string | varies | Sort field (endpoint-specific) | | order | string | desc | Sort direction: `asc` or `desc` | | locale | string | en | Content language: en, zh, zh-TW, ja, ko | ### GET /api/v1/experts List verified travel experts (paginated). **Filters:** `city`, `language`, `specialty`, `country` (CountryCode: CN, JP, KR, TW, HK) **Sort fields:** `rating` (default), `reviewCount`, `createdAt` **Response (200):** ```json { "data": [ { "handle": "sarah-beijing", "name": "Sarah", "avatar": "https://...", "bio": "Local Beijing guide...", "languages": ["English", "Chinese"], "specialties": ["Food Tours", "Culture & History"], "location": "Beijing", "countryCode": "CN", "currency": "USD", "profileImage": "https://...", "hourlyRate": 50, "rating": 4.8, "reviewCount": 42 } ], "meta": { "page": 1, "pageSize": 20, "total": 150, "hasMore": true } } ``` ### GET /api/v1/experts/{handle} Get a single expert's full profile. **Response (200):** ```json { "data": { "handle": "sarah-beijing", "name": "Sarah", "bio": "...", "languages": ["English", "Chinese"], "specialties": ["Food Tours"], "location": "Beijing", "countryCode": "CN", "currency": "USD", "profileImage": "https://...", "coverImage": "https://...", "hourlyRate": 50, "rating": 4.8, "reviewCount": 42, "socialLinks": { "instagram": "..." }, "messagingEnabled": true, "weeklySchedule": { "1": [{"start":"09:00","end":"17:00"}] }, "timezone": "Asia/Shanghai", "verifiedAt": "2025-01-15T00:00:00Z" } } ``` **Errors:** 404 if expert not found or not verified ### GET /api/v1/experts/{handle}/packages List packages associated with an expert (via bookings). Paginated. **Errors:** 404 if expert not found ### GET /api/v1/experts/{handle}/reviews List public reviews for an expert. Paginated. **Sort fields:** `createdAt` (default), `rating` **Response item:** ```json { "id": "uuid", "rating": 5, "comment": "Amazing experience!", "createdAt": "2025-03-01T00:00:00Z", "author": { "name": "John", "avatar": "https://..." } } ``` **Errors:** 404 if expert not found ### GET /api/v1/packages List all active travel packages (paginated). **Filters:** `category` (e.g., city_tour, food_tour), `country` (CountryCode) **Sort fields:** `createdAt` (default), `basePrice`, `duration` **Response item:** ```json { "id": "uuid", "title": "Beijing Food Tour", "description": "...", "category": "food_tour", "countryCode": "CN", "basePrice": 120, "currency": "USD", "duration": 4, "maxParticipants": 6, "images": ["https://..."], "highlights": ["Visit local markets", "Try street food"], "included": ["Lunch", "Transport"], "meetingPoint": "Wangfujing Station" } ``` ### GET /api/v1/packages/{id} Get a single package by ID. Includes `excluded` items in addition to list fields. **Errors:** 404 if package not found or inactive ### GET /api/v1/travelogues List published travelogues (paginated). **Filters:** `category` (food, adventure, culture, nature) **Sort fields:** `hotScore` (default), `score`, `viewCount`, `createdAt`, `publishedAt` **Response item:** ```json { "slug": "best-ramen-in-tokyo", "title": "Best Ramen in Tokyo", "excerpt": "A guide to...", "coverImage": "https://...", "category": "food", "locationTags": ["Tokyo", "Shinjuku"], "score": 42, "viewCount": 1200, "commentCount": 15, "publishedAt": "2025-02-10T00:00:00Z", "author": { "handle": "tokyo-foodie", "name": "Yuki", "avatar": "https://..." } } ``` ### GET /api/v1/travelogues/{slug} Get a single travelogue by slug. Includes full `content` (HTML), `images` array, and `createdAt`/`updatedAt`. **Errors:** 404 if travelogue not found or not published ### GET /api/v1/cities List distinct cities where verified experts are located. No pagination. **Response (200):** ```json { "data": [ { "name": "Beijing", "countryCode": "CN" }, { "name": "Tokyo", "countryCode": "JP" } ] } ``` ### GET /api/v1/specialties List distinct specialties offered by verified experts. No pagination. **Response (200):** ```json { "data": ["city_tour", "culture", "food_tour", "nature", "shopping"] } ``` --- ## Public Pages & Content ### Expert Profiles URL pattern: `/{locale}/{expert-handle}` (e.g., `/en/sarah-beijing`) Expert profile data includes: - Name, bio (English + Chinese), profile image - Languages spoken, specialties, city - Hourly rate, currency - Rating (1-5), review count - Available packages - Availability schedule ### Travelogues URL pattern: `/{locale}/travelogues/{slug}` Travelogue data includes: - Title, excerpt, full content (English + Chinese) - Author (expert profile) - Cover image, category, location tags - Vote score (upvotes/downvotes), comment count - Published date, last updated ### Packages URL pattern: `/{locale}/packages/{id}` Package data includes: - Title, description (English + Chinese) - Price, currency, duration - Max participants, meeting point - Highlights, images - Expert who offers it --- ## Supported Locales | Code | Language | URL prefix | | ----- | -------------------- | ---------- | | en | English | /en/ | | zh | Chinese (Simplified) | /zh/ | | zh-TW | Chinese (Traditional)| /zh-TW/ | | ja | Japanese | /ja/ | | ko | Korean | /ko/ | All public pages and API responses support bilingual content. Database stores both English (`field`) and Chinese (`fieldZh`) values. --- ## Schema.org Structured Data All public pages include JSON-LD structured data: - **Every page**: `Organization`, described as a demo - **Home page**: `WebSite` with `SearchAction` - **Travelogue articles**: `Article` with author, dates, publisher - **Navigation**: `BreadcrumbList` on all detail pages There is deliberately no `Person`, `Product`, `Offer`, `AggregateRating`, `TouristTrip` or `LocalBusiness` markup: the experts, packages and ratings are sample data on a demo site, and that markup would describe them as real. --- ## OpenAPI Specification A machine-readable OpenAPI 3.1 specification is available at: ``` GET /api/openapi.json ``` 41 operations across 9 tags: every public and authenticated endpoint, with: - Unique `operationId` for each endpoint (e.g., `listExperts`, `loginWithCredentials`) - Request/response schemas with types, formats, and examples - Authentication requirements (Bearer JWT) - Rate limit tiers per endpoint category - Error response schema with self-healing metadata Use this spec for automated API client generation, agent tool registration, and semantic routing in AI agent frameworks. --- ## Technical Details - **Framework**: Next.js 16 (App Router) - **Database**: MySQL 8 via Prisma ORM - **Real-time messaging**: Agora Chat SDK - **Push notifications**: Firebase Cloud Messaging - **File storage**: AWS S3 - **PWA**: Full Progressive Web App with offline support