{"servers":[{"url":"http://localhost:3000","description":"Development server"},{"url":"https://api.user-x.com","description":"Production server (replace with your deployment host)"}],"openapi":"3.0.0","info":{"title":"User-X End-User API","version":"0.1.0","description":"Client-facing endpoints for authenticated end users and embedded widgets. Use the sidebar categories to browse. Admin API tokens do not represent an end-user session — see `/api/docs/admin` for server-to-server administration."},"components":{"schemas":{"User":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string","nullable":true},"username":{"type":"string","nullable":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"emailVerified":{"type":"boolean"},"image":{"type":"string","nullable":true},"role":{"type":"string"}},"required":["id","email","username","createdAt","updatedAt","emailVerified","image","role"]},"Task":{"type":"object","properties":{"id":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"type":{"type":"string"},"description":{"type":"string"},"opportunityId":{"type":"string","nullable":true},"order":{"type":"integer"},"active":{"type":"boolean"},"surveyId":{"type":"string","nullable":true}},"required":["id","createdAt","updatedAt","type","description","opportunityId","order","active","surveyId"]},"Opportunity":{"type":"object","properties":{"id":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"active":{"type":"boolean"},"rewardId":{"type":"string","nullable":true}},"required":["id","createdAt","updatedAt","name","description","active","rewardId"]},"Claim":{"type":"object","properties":{"id":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"userId":{"type":"string"},"opportunityId":{"type":"string"},"completed":{"type":"boolean"},"opportunityLocationId":{"type":"string","nullable":true}},"required":["id","createdAt","updatedAt","userId","opportunityId","completed","opportunityLocationId"]},"TaskVerification":{"type":"object","properties":{"id":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"claimId":{"type":"string","nullable":true},"userId":{"type":"string","nullable":true},"taskId":{"type":"string","nullable":true},"status":{"type":"string"},"notes":{"type":"string","nullable":true}},"required":["id","createdAt","updatedAt","claimId","userId","taskId","status","notes"]},"TaskVerificationHistory":{"type":"object","properties":{"id":{"type":"string"},"started":{"type":"boolean"},"startedAt":{"type":"string","nullable":true},"submitted":{"type":"boolean"},"submittedAt":{"type":"string","nullable":true},"reviewed":{"type":"boolean"},"approved":{"type":"boolean"},"approvedAt":{"type":"string","nullable":true},"rejected":{"type":"boolean"},"rejectedAt":{"type":"string","nullable":true},"rejectedReason":{"type":"string","nullable":true},"text":{"type":"string","nullable":true},"link":{"type":"string","nullable":true},"image":{"type":"string","nullable":true},"lat":{"type":"number","nullable":true},"long":{"type":"number","nullable":true},"attemptNumber":{"type":"number"},"verificationId":{"type":"string"},"taskId":{"type":"string","nullable":true},"userId":{"type":"string","nullable":true},"claimId":{"type":"string","nullable":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","started","startedAt","submitted","submittedAt","reviewed","approved","approvedAt","rejected","rejectedAt","rejectedReason","text","link","image","lat","long","attemptNumber","verificationId","taskId","userId","claimId","createdAt","updatedAt"]},"SurveyResponse":{"type":"object","properties":{"id":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"verificationId":{"type":"string"},"fieldId":{"type":"string","description":"Alternative camelCase format (deprecated, use field_id instead)"},"value":{"type":"string"},"field_id":{"type":"string","description":"The ID of the survey field (snake_case preferred, but fieldId also supported for backwards compatibility)"},"response":{"type":"string","description":"The response value (can be a string or array for multi-select fields)"}},"required":["id","createdAt","updatedAt","verificationId","fieldId","value"]},"ClaimResponse":{"type":"object","properties":{"claim":{"type":"object","properties":{"id":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"userId":{"type":"string"},"opportunityId":{"type":"string"},"completed":{"type":"boolean"},"opportunityLocationId":{"type":"string","nullable":true},"verifications":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"claimId":{"type":"string","nullable":true},"userId":{"type":"string","nullable":true},"taskId":{"type":"string","nullable":true},"status":{"type":"string"},"notes":{"type":"string","nullable":true},"surveyResponses":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"verificationId":{"type":"string"},"fieldId":{"type":"string"},"value":{"type":"string"}},"required":["id","createdAt","updatedAt","verificationId","fieldId","value"]}}},"required":["id","createdAt","updatedAt","claimId","userId","taskId","status","notes"]}},"opportunity":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"active":{"type":"boolean"},"rewardId":{"type":"string","nullable":true}},"required":["id","createdAt","updatedAt","name","description","active","rewardId"]},"user":{"type":"object","nullable":true,"properties":{"id":{"type":"string"},"email":{"type":"string","nullable":true},"username":{"type":"string","nullable":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"},"emailVerified":{"type":"boolean"},"image":{"type":"string","nullable":true},"role":{"type":"string"}},"required":["id","email","username","createdAt","updatedAt","emailVerified","image","role"]}},"required":["id","createdAt","updatedAt","userId","opportunityId","completed","opportunityLocationId"],"$ref":"#/components/schemas/Claim"},"taskVerifications":{"type":"array","items":{"$ref":"#/components/schemas/TaskVerification"}},"data":{"$ref":"#/components/schemas/ClaimResponse"}},"required":["claim"]},"Coupon":{"type":"object","properties":{"id":{"type":"string"},"amount":{"type":"number","nullable":true},"couponType":{"type":"string"},"percentage":{"type":"number","nullable":true},"description":{"type":"string","nullable":true},"expires":{"type":"string"},"redeemed":{"type":"boolean"},"redeemedAt":{"type":"string","nullable":true},"external":{"type":"boolean","nullable":true},"webhookUrl":{"type":"string","nullable":true},"restriction":{"type":"string","nullable":true},"ownerId":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","amount","couponType","percentage","description","expires","redeemed","redeemedAt","external","webhookUrl","restriction","ownerId","createdAt","updatedAt"]},"Voucher":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"amount":{"type":"number","nullable":true},"description":{"type":"string","nullable":true},"expires":{"type":"string"},"redeemed":{"type":"boolean"},"redeemedAt":{"type":"string","nullable":true},"external":{"type":"boolean","nullable":true},"webhookUrl":{"type":"string","nullable":true},"restriction":{"type":"string","nullable":true},"ownerId":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","text","amount","description","expires","redeemed","redeemedAt","external","webhookUrl","restriction","ownerId","createdAt","updatedAt"]},"RedemptionLocation":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"address":{"type":"string"},"latitude":{"type":"number","nullable":true},"longitude":{"type":"number","nullable":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","name","address","latitude","longitude","createdAt","updatedAt"]},"Survey":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string","nullable":true},"fields":{"type":"array","items":{"nullable":true,"$ref":"#/components/schemas/SurveyField"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"fieldOrder":{"type":"array","items":{"type":"string"}},"name":{"type":"string"},"active":{"type":"boolean"}},"required":["id","title","description","fields","createdAt","updatedAt"]},"Profile":{"type":"object","properties":{"id":{"type":"string"},"email":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"username":{"type":"string","nullable":true},"firstName":{"type":"string","nullable":true},"lastName":{"type":"string","nullable":true},"middleName":{"type":"string","nullable":true},"profilePicture":{"type":"string","nullable":true},"about":{"type":"string","nullable":true},"rank":{"type":"string"},"xp":{"type":"number","nullable":true},"confirmed":{"type":"boolean"},"active":{"type":"boolean"},"emailNotifications":{"type":"boolean"},"reputation":{"type":"number"},"featureFlags":{"nullable":true},"coupons":{"type":"array","items":{"nullable":true}},"vouchers":{"type":"array","items":{"nullable":true}},"favoriteLocations":{"type":"array","items":{"type":"string"}},"tfaMethods":{"type":"array","items":{"type":"string"}},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","email","phone","username","firstName","lastName","middleName","profilePicture","about","rank","confirmed","active","emailNotifications","reputation","createdAt"]},"Redemption":{"type":"object","properties":{"id":{"type":"string"},"owner":{"type":"string"},"approved":{"type":"boolean"},"rejected":{"type":"boolean"},"canceled":{"type":"boolean"},"approvedAt":{"type":"string","nullable":true},"rejectedAt":{"type":"string","nullable":true},"canceledAt":{"type":"string","nullable":true},"rejectionReason":{"type":"string","nullable":true},"rewardType":{"type":"string"},"rewardId":{"type":"string","nullable":true},"redemptionLocationId":{"type":"string","nullable":true},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","owner","approved","approvedAt","rewardType","rewardId","redemptionLocationId"]},"ExternalRedemptionResponse":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"},"instructions":{"type":"string"},"externalId":{"type":"string"},"data":{"nullable":true}},"required":["success"]},"CouponRedemptionResponse":{"type":"object","properties":{"success":{"type":"boolean"},"promoCode":{"type":"string"},"externalResponse":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"},"instructions":{"type":"string"},"externalId":{"type":"string"},"data":{"nullable":true}},"required":["success"]},"coupon":{"type":"object","properties":{"id":{"type":"string"},"amount":{"type":"number","nullable":true},"couponType":{"type":"string"},"percentage":{"type":"number","nullable":true},"description":{"type":"string","nullable":true},"expires":{"type":"string"},"redeemed":{"type":"boolean"},"redeemedAt":{"type":"string","nullable":true},"external":{"type":"boolean","nullable":true},"webhookUrl":{"type":"string","nullable":true},"restriction":{"type":"string","nullable":true},"ownerId":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","amount","couponType","percentage","description","expires","redeemed","redeemedAt","external","webhookUrl","restriction","ownerId","createdAt","updatedAt"]}},"required":["success","coupon"]},"VoucherRedemptionResponse":{"type":"object","properties":{"success":{"type":"boolean"},"promoCode":{"type":"string"},"externalResponse":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"},"instructions":{"type":"string"},"externalId":{"type":"string"},"data":{"nullable":true}},"required":["success"]},"voucher":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"amount":{"type":"number","nullable":true},"description":{"type":"string","nullable":true},"expires":{"type":"string"},"redeemed":{"type":"boolean"},"redeemedAt":{"type":"string","nullable":true},"external":{"type":"boolean","nullable":true},"webhookUrl":{"type":"string","nullable":true},"restriction":{"type":"string","nullable":true},"ownerId":{"type":"string"},"createdAt":{"type":"string"},"updatedAt":{"type":"string"}},"required":["id","text","amount","description","expires","redeemed","redeemedAt","external","webhookUrl","restriction","ownerId","createdAt","updatedAt"]}},"required":["success","voucher"]},"ErrorResponse":{"type":"object","properties":{"error":{"type":"string"}},"required":["error"]},"WebhookUser":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"},"email":{"type":"string"}},"required":["id","username","email"]},"WebhookCoupon":{"type":"object","properties":{"id":{"type":"string"},"couponType":{"type":"string"},"amount":{"type":"number","nullable":true},"percentage":{"type":"number","nullable":true},"description":{"type":"string","nullable":true},"expires":{"type":"string"},"restriction":{"type":"string","nullable":true}},"required":["id","couponType","amount","percentage","description","expires","restriction"]},"WebhookVoucher":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"amount":{"type":"number","nullable":true},"description":{"type":"string","nullable":true},"expires":{"type":"string"},"restriction":{"type":"string","nullable":true}},"required":["id","text","amount","description","expires","restriction"]},"CouponWebhookPayload":{"type":"object","properties":{"couponId":{"type":"string"},"userId":{"type":"string"},"user":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"},"email":{"type":"string"}},"required":["id","username","email"]},"coupon":{"type":"object","properties":{"id":{"type":"string"},"couponType":{"type":"string"},"amount":{"type":"number","nullable":true},"percentage":{"type":"number","nullable":true},"description":{"type":"string","nullable":true},"expires":{"type":"string"},"restriction":{"type":"string","nullable":true}},"required":["id","couponType","amount","percentage","description","expires","restriction"]},"timestamp":{"type":"string"}},"required":["couponId","userId","user","coupon","timestamp"]},"VoucherWebhookPayload":{"type":"object","properties":{"voucherId":{"type":"string"},"userId":{"type":"string"},"user":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"},"email":{"type":"string"}},"required":["id","username","email"]},"voucher":{"type":"object","properties":{"id":{"type":"string"},"text":{"type":"string"},"amount":{"type":"number","nullable":true},"description":{"type":"string","nullable":true},"expires":{"type":"string"},"restriction":{"type":"string","nullable":true}},"required":["id","text","amount","description","expires","restriction"]},"timestamp":{"type":"string"}},"required":["voucherId","userId","user","voucher","timestamp"]},"PointClaim":{"type":"object","properties":{"id":{"type":"string"},"amount":{"type":"number"},"description":{"type":"string","nullable":true},"referenceType":{"type":"string","nullable":true},"referenceId":{"type":"string","nullable":true},"createdAt":{"type":"string"}},"required":["id","amount","description","referenceType","referenceId","createdAt"]},"ClaimablePointsResponse":{"type":"object","properties":{"claims":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"amount":{"type":"number"},"description":{"type":"string","nullable":true},"referenceType":{"type":"string","nullable":true},"referenceId":{"type":"string","nullable":true},"createdAt":{"type":"string"}},"required":["id","amount","description","referenceType","referenceId","createdAt"]}},"totalPoints":{"type":"number"}},"required":["claims","totalPoints"]},"ClaimPointsRequest":{"type":"object","properties":{"claimIds":{"type":"array","items":{"type":"string","format":"uuid"},"minItems":1}}},"ClaimPointsResponse":{"type":"object","properties":{"claimedCount":{"type":"number"},"pointsClaimed":{"type":"number"},"transactionId":{"type":"string","nullable":true},"balance":{"type":"number","nullable":true}},"required":["claimedCount","pointsClaimed","transactionId","balance"]},"Error":{"type":"object","properties":{"error":{"type":"string"}}},"Reward":{"type":"object","properties":{"id":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"opportunity":{"$ref":"#/components/schemas/Opportunity"}}},"SurveyField":{"type":"object","properties":{"id":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"global":{"type":"boolean"},"label":{"type":"string"},"language":{"type":"string"},"maxChars":{"type":"integer"},"options":{"type":"array","items":{"type":"string"}},"placeholder":{"type":"string"},"required":{"type":"boolean"},"type":{"type":"string","enum":["TextArea","Input","Image","MultiChoice","Sentiment","Checkbox"]},"hasOpenTextResponse":{"type":"boolean","description":"Whether this field has an accompanying open text response"},"openTextResponseLabel":{"type":"string","description":"Label for the open text response"},"openTextResponseRequired":{"type":"boolean","description":"Whether the open text response is required"},"openTextMaxChars":{"type":"integer","description":"Maximum characters allowed for the open text response"}}},"TaskWithDetails":{"allOf":[{"$ref":"#/components/schemas/Task"},{"type":"object","properties":{"opportunity":{"$ref":"#/components/schemas/Opportunity"},"survey":{"$ref":"#/components/schemas/SurveyWithFields"}}}]},"SurveyWithFields":{"allOf":[{"$ref":"#/components/schemas/Survey"},{"type":"object","properties":{"fields":{"type":"array","items":{"$ref":"#/components/schemas/SurveyField"}}}}]},"Asset":{"type":"object","properties":{"caption":{"type":"string"},"duration":{"type":"number"},"exif":{"type":"string"},"height":{"type":"number"},"mediaSubtypes":{"type":"array","items":{"type":"string"}},"mediaType":{"type":"string"},"uri":{"type":"string"},"width":{"type":"number"}}}},"parameters":{},"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"End-user session JWT or widget SSO JWT. Pass as `Authorization: Bearer <token>`. Identity may also be supplied via middleware-injected `x-user-id`. This scheme does not grant access to admin endpoints."}}},"paths":{"/api/activity":{"get":{"summary":"Get new activities since timestamp","description":"Retrieve activities created after a specific timestamp (for polling)","tags":["Activity Feed"],"parameters":[{"in":"query","name":"since","required":true,"schema":{"type":"string","format":"date-time"},"description":"ISO timestamp to fetch activities since"}],"responses":{"200":{"description":"Activities retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"activities":{"type":"array"},"count":{"type":"integer"},"timestamp":{"type":"string","format":"date-time"}}}}}},"400":{"description":"Bad request - missing since parameter"},"500":{"description":"Internal server error"}}}},"/api/analytics/sentiment/{surveyId}":{"get":{"summary":"Get sentiment analytics for a survey","description":"Retrieve sentiment analytics including distribution, average score, and trend for a specific survey","tags":["Analytics"],"parameters":[{"in":"path","name":"surveyId","required":true,"schema":{"type":"string"},"description":"The ID of the survey to analyze"}],"responses":{"200":{"description":"Successful response with sentiment analytics","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"analytics":{"type":"object","properties":{"totalResponses":{"type":"integer","description":"Total number of sentiment responses"},"averageScore":{"type":"number","description":"Average sentiment score (0-2)"},"distribution":{"type":"object","properties":{"dissatisfied":{"type":"object","properties":{"count":{"type":"integer"},"percentage":{"type":"integer"}}},"neutral":{"type":"object","properties":{"count":{"type":"integer"},"percentage":{"type":"integer"}}},"satisfied":{"type":"object","properties":{"count":{"type":"integer"},"percentage":{"type":"integer"}}}}},"sentimentTrend":{"type":"string","enum":["positive","neutral","negative"]}}}}}}}},"404":{"description":"Survey not found","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"error":{"type":"string"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"error":{"type":"string"}}}}}}}}},"/api/auth/sso":{"post":{"summary":"SSO Authentication","description":"Authenticate a user via SSO client and get JWT token for widget access","tags":["Authentication","SSO"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["clientId","clientSecret","userIdentifier"],"properties":{"clientId":{"type":"string","description":"SSO client ID"},"clientSecret":{"type":"string","description":"SSO client secret"},"userIdentifier":{"type":"string","description":"User identifier. Interpreted according to userIdentifierType; when that field is omitted, values containing '@' are treated as email addresses and everything else as a phone number.\n"},"userIdentifierType":{"type":"string","enum":["email","phone","lhtUuid"],"description":"Optional. How to interpret userIdentifier. Use 'lhtUuid' to authenticate with your own user UUID (max 50 characters) instead of a contact channel.\n"},"userData":{"type":"object","description":"Optional user data for account creation/update","properties":{"firstName":{"type":"string"},"lastName":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string"}}}}}}}},"responses":{"200":{"description":"Authentication successful","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"JWT token for widget API access"},"userId":{"type":"string","description":"User ID in the system"},"expiresIn":{"type":"string","description":"Token expiry time"}}}}}},"400":{"description":"Bad request"},"401":{"description":"Invalid client credentials"},"500":{"description":"Internal server error"},"503":{"description":"Tenant contact lookup unavailable while resolving an lhtUuid identifier. Retry later; issuing a token anyway could create a duplicate account.\n"}}}},"/api/auth/verify":{"get":{"summary":"Verify user authentication","description":"Verifies that the user is authenticated and returns user information","tags":["Authentication"],"parameters":[{"in":"header","name":"x-user-id","required":true,"schema":{"type":"string"},"description":"User ID for authentication"}],"responses":{"200":{"description":"User is authenticated","content":{"application/json":{"schema":{"type":"object","properties":{"authenticated":{"type":"boolean"},"user":{"$ref":"#/components/schemas/User"}}}}}},"401":{"description":"Unauthorized - User ID not found in headers"},"404":{"description":"User not found"}}}},"/api/campaigns/public/{id}/click":{"post":{"summary":"Record a public campaign click","description":"Increments the campaign click counter (fire-and-forget from widgets). Public and rate-limited; only counts campaigns that are currently visible.\n","tags":["Campaigns","Campaign Tracking"],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"},"description":"Campaign (opportunity) ID"}],"responses":{"200":{"description":"Click recorded (or no-op if campaign not countable)","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"400":{"description":"Invalid campaign ID format"},"429":{"description":"Rate limited"},"500":{"description":"Internal server error"}}}},"/api/campaigns/public/{id}":{"get":{"summary":"Get a public campaign by ID","description":"Returns a single active, currently-live public campaign (opportunity) with tasks, surveys, and reward summary. Does not increment view counters — use POST /api/campaigns/public/{id}/view for that.\n","tags":["Campaigns"],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"},"description":"Campaign (opportunity) ID"}],"responses":{"200":{"description":"Campaign retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"campaign":{"type":"object"}}}}}},"400":{"description":"Missing or invalid campaign ID"},"404":{"description":"Campaign not found, not live, or outside its date window"},"500":{"description":"Internal server error"}}}},"/api/campaigns/public/{id}/view":{"post":{"summary":"Record a public campaign view","description":"Increments the campaign view counter once per detail open. Fired explicitly by the widget — not as a side effect of the detail GET (which refetches on realtime updates). Public and rate-limited.\n","tags":["Campaigns","Campaign Tracking"],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string","format":"uuid"},"description":"Campaign (opportunity) ID"}],"responses":{"200":{"description":"View recorded (or no-op if campaign not countable)","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"400":{"description":"Invalid campaign ID format"},"429":{"description":"Rate limited"},"500":{"description":"Internal server error"}}}},"/api/campaigns/public":{"get":{"summary":"Get public campaigns","description":"Retrieves a list of active, currently-live public campaigns (opportunities). Optional `tags` query filters to campaigns that have all of the named shop tags.\n","tags":["Campaigns"],"parameters":[{"in":"query","name":"limit","schema":{"type":"integer","default":5,"maximum":20},"description":"Maximum number of campaigns to return (capped at 20)"},{"in":"query","name":"tags","schema":{"type":"array"},"description":"Filter campaigns by tag names"}],"responses":{"200":{"description":"List of public campaigns","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Opportunity"}}}}},"500":{"description":"Internal server error"}}}},"/api/comments/report":{"post":{"summary":"Report a comment","description":"Submit a report for an inappropriate or rule-violating comment. Requires authentication.","tags":["Comments","Moderation"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["commentId","reason","category"],"properties":{"commentId":{"type":"string","format":"uuid","description":"The UUID of the comment being reported"},"reporterId":{"type":"string","description":"The ID of the user submitting the report (extracted from token if not provided)"},"reporterUsername":{"type":"string","description":"Username of the reporter (extracted from token if not provided)"},"reportedUserId":{"type":"string","description":"The ID of the user who authored the comment (fetched from comment if not provided)"},"reportedUsername":{"type":"string","description":"Username of the reported user (fetched from comment if not provided)"},"reason":{"type":"string","description":"Brief reason for the report"},"category":{"type":"string","enum":["spam","harassment","inappropriate","misinformation","other"],"description":"Category of the violation"},"description":{"type":"string","description":"Additional details about the report"}}}}}},"responses":{"200":{"description":"Report submitted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"reportId":{"type":"string","format":"uuid"},"message":{"type":"string"}}}}}},"400":{"description":"Bad request - missing required fields, invalid category, or invalid commentId format","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"401":{"description":"Unauthorized - authentication required","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"404":{"description":"Comment not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"409":{"description":"Conflict - user has already reported this comment","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}}}}},"/api/comments":{"post":{"summary":"Create a comment on a community post","description":"Creates a new comment on a community post","tags":["Community"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["postId","text"],"properties":{"postId":{"type":"string","description":"ID of the post to comment on"},"text":{"type":"string","description":"Content of the comment"},"userId":{"type":"string","description":"ID of the user creating the comment"},"username":{"type":"string","description":"Username of the commenter"}}}}}},"responses":{"200":{"description":"Comment created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"commentId":{"type":"string"}}}}}},"400":{"description":"Bad request - missing required fields"},"500":{"description":"Internal server error"}}}},"/api/community-posts":{"post":{"summary":"Create a new community post","tags":["Community"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["body","userId","username"],"properties":{"body":{"type":"string"},"title":{"type":"string"},"images":{"type":"array","items":{"type":"string"}},"userId":{"type":"string"},"username":{"type":"string"},"avatar":{"type":"string"},"groupId":{"type":"string"}}}}}},"responses":{"200":{"description":"Post created successfully"},"400":{"description":"Bad request - missing required fields"},"500":{"description":"Internal server error"}}}},"/api/feed":{"get":{"summary":"Get activity feed","description":"Retrieve paginated activity feed with optional filtering","tags":["Activity Feed"],"parameters":[{"in":"query","name":"cursor","required":false,"schema":{"type":"string"},"description":"Pagination cursor for fetching next page"},{"in":"query","name":"limit","required":false,"schema":{"type":"integer","default":20,"maximum":50},"description":"Maximum number of items to return (max 50)"},{"in":"query","name":"types","required":false,"schema":{"type":"string"},"description":"Comma-separated list of activity types to filter"},{"in":"query","name":"since","required":false,"schema":{"type":"string","format":"date-time"},"description":"ISO timestamp to fetch activities since"}],"responses":{"200":{"description":"Feed retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array"},"nextCursor":{"type":"string","nullable":true},"hasMore":{"type":"boolean"},"meta":{"type":"object","properties":{"activityRate":{"type":"number"},"refreshInterval":{"type":"number"}}}}}}}},"500":{"description":"Internal server error"}}}},"/api/login":{"post":{"summary":"User login or code verification","description":"Sends a verification code or verifies the code to log in the user. To send the code, simply post without a code","tags":["Authentication"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"phone":{"type":"string","description":"The user's phone number"},"code":{"type":"string","description":"The verification code (optional)"}}}}}},"responses":{"200":{"description":"Successful operation","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","description":"Success message when code is sent"},"token":{"type":"string","description":"JWT token when login is successful"}}}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"User not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/notifications":{"get":{"summary":"Get user notifications","description":"Retrieve latest shop notifications from Supabase","tags":["Notifications"],"parameters":[{"in":"query","name":"userId","required":false,"schema":{"type":"string"},"description":"Deprecated. Ignored. Supabase notifications are shop-scoped."},{"in":"query","name":"limit","required":false,"schema":{"type":"integer","default":10},"description":"Maximum number of notifications to return"}],"responses":{"200":{"description":"Notifications retrieved successfully","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"type":{"type":"string"},"message":{"type":"string"},"image":{"type":"string"},"entity_id":{"type":"string"},"created_at":{"type":"string"},"read_at":{"type":"string","nullable":true}}}}}}},"400":{"description":"Bad request - missing userId"},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}}},"/api/opportunities":{"get":{"summary":"Get active opportunities","description":"Retrieves all active opportunities available to the authenticated user, filtered by attempt limits","tags":["Opportunities"],"parameters":[{"in":"header","name":"x-user-id","required":true,"schema":{"type":"string"},"description":"User ID for authentication"}],"responses":{"200":{"description":"List of active opportunities","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Opportunity"}}}}},"401":{"description":"Unauthorized - User ID not found in headers"},"500":{"description":"Internal server error"}}}},"/api/posts":{"post":{"summary":"Create a new social post","description":"Creates a new post in the social feed and emits a notification","tags":["Social"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["body","poster","userId"],"properties":{"body":{"type":"string","description":"Content of the post"},"poster":{"type":"string","description":"Username of the poster"},"userId":{"type":"string","description":"ID of the user creating the post"},"type":{"type":"string","enum":["Post","BusinessPost"],"description":"Type of post"},"images":{"type":"array","items":{"type":"string"},"description":"Array of image URLs"},"avatar":{"type":"string","description":"Avatar URL of the poster"},"channel_id":{"type":"string","description":"Optional channel ID for the post"}}}}}},"responses":{"200":{"description":"Post created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"postId":{"type":"string"}}}}}},"400":{"description":"Bad request - missing required fields or invalid post type"},"500":{"description":"Internal server error"}}},"get":{"summary":"Get social posts","description":"Retrieves the latest posts from the social feed, optionally filtered by channel","tags":["Social"],"parameters":[{"in":"query","name":"limit","schema":{"type":"integer","default":10},"description":"Maximum number of posts to return"},{"in":"query","name":"channel","schema":{"type":"string"},"description":"Filter posts by channel name or slug"}],"responses":{"200":{"description":"List of posts","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"body":{"type":"string"},"poster":{"type":"string"},"avatar":{"type":"string"},"images":{"type":"array","items":{"type":"string"}},"type":{"type":"string"},"created_at":{"type":"string","format":"date-time"}}}}}}},"500":{"description":"Internal server error"}}}},"/api/presence/count":{"get":{"summary":"Get online user count","description":"Get the count of online users for a specific scope","tags":["Presence"],"parameters":[{"in":"query","name":"shop","required":true,"schema":{"type":"string"},"description":"Shop identifier"},{"in":"query","name":"scope","schema":{"type":"string","enum":["shop","group","channel"],"default":"shop"},"description":"Presence scope"},{"in":"query","name":"scopeId","schema":{"type":"string","format":"uuid"},"description":"ID of group or channel (for group/channel scope)"},{"in":"query","name":"timeout","schema":{"type":"integer","default":120},"description":"Timeout in seconds to consider a user online"}],"responses":{"200":{"description":"Online count retrieved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"data":{"type":"object","properties":{"count":{"type":"integer"},"scope":{"type":"string"},"scopeId":{"type":"string"}}}}}}}},"400":{"description":"Missing required parameters"},"500":{"description":"Internal server error"}}}},"/api/presence/heartbeat":{"post":{"summary":"Record user presence heartbeat","description":"Update user's online presence status. Should be called every 30 seconds.","tags":["Presence"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["shop","userId"],"properties":{"shop":{"type":"string","description":"Shop identifier"},"userId":{"type":"string","description":"User ID"},"clientType":{"type":"string","enum":["web","mobile","widget"],"default":"web"},"clientVersion":{"type":"string","description":"Client version"},"scopes":{"type":"array","items":{"type":"object","properties":{"scope":{"type":"string","enum":["shop","group","channel"]},"scopeId":{"type":"string"}}}}}}}}},"responses":{"200":{"description":"Heartbeat recorded successfully"},"400":{"description":"Missing required parameters"},"500":{"description":"Internal server error"}}},"delete":{"summary":"Remove user presence","description":"Remove user's online presence when they disconnect","tags":["Presence"],"parameters":[{"in":"query","name":"shop","required":true,"schema":{"type":"string"}},{"in":"query","name":"userId","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Presence removed successfully"},"400":{"description":"Missing required parameters"},"500":{"description":"Internal server error"}}}},"/api/reactions":{"post":{"summary":"Add a reaction to a post","description":"Creates a new reaction (like, love, etc.) on a social post","tags":["Social"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["postId","userId","type"],"properties":{"postId":{"type":"string","description":"ID of the post to react to"},"userId":{"type":"string","description":"ID of the user creating the reaction"},"type":{"type":"string","description":"Type of reaction (e.g., like, love, etc.)"}}}}}},"responses":{"200":{"description":"Reaction created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"400":{"description":"Bad request - missing required fields"},"500":{"description":"Internal server error"}}},"delete":{"summary":"Remove a reaction from a post","description":"Deletes a user's reaction from a social post","tags":["Social"],"parameters":[{"in":"query","name":"postId","required":true,"schema":{"type":"string"},"description":"ID of the post"},{"in":"query","name":"userId","required":true,"schema":{"type":"string"},"description":"ID of the user"}],"responses":{"200":{"description":"Reaction deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"400":{"description":"Bad request - missing required fields"},"500":{"description":"Internal server error"}}}},"/api/redeem/{couponId}":{"get":{"summary":"Get public coupon preview for QR code redemption (App Clip-like experience)","description":"Returns limited coupon information for display before authentication. Does not require user authentication.","tags":["Redemption"],"parameters":[{"in":"path","name":"couponId","required":true,"schema":{"type":"string"},"description":"The ID of the coupon to preview"}],"responses":{"200":{"description":"Successfully retrieved coupon preview","content":{"application/json":{"schema":{"type":"object","properties":{"coupon":{"type":"object","properties":{"id":{"type":"string"},"coupon_type":{"type":"string"},"amount":{"type":"number"},"percentage":{"type":"number"},"description":{"type":"string"},"expires":{"type":"string"},"redeemed":{"type":"boolean"},"image":{"type":"string"}}}}}}}},"404":{"description":"Coupon not found"},"500":{"description":"Internal server error"}}}},"/api/redeem/voucher/{voucherId}":{"get":{"summary":"Get public voucher preview for QR code redemption (App Clip-like experience)","description":"Returns limited voucher information for display before authentication. Does not require user authentication.","tags":["Redemption"],"parameters":[{"in":"path","name":"voucherId","required":true,"schema":{"type":"string"},"description":"The ID of the voucher to preview"}],"responses":{"200":{"description":"Successfully retrieved voucher preview","content":{"application/json":{"schema":{"type":"object","properties":{"voucher":{"type":"object","properties":{"id":{"type":"string"},"amount":{"type":"number"},"text":{"type":"string"},"description":{"type":"string"},"expires":{"type":"string"},"redeemed":{"type":"boolean"},"image":{"type":"string"}}}}}}}},"404":{"description":"Voucher not found"},"500":{"description":"Internal server error"}}}},"/api/redemption-locations/by-tags":{"post":{"summary":"Get redemption locations by tag IDs","description":"Retrieve redemption locations that match the specified tag IDs. Handles the special \"ALL\" tag.","tags":["Redemption Locations"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["tagIds"],"properties":{"tagIds":{"type":"array","items":{"type":"string"},"description":"Array of redemption location tag IDs. Use \"ALL\" for all locations."},"includeInactive":{"type":"boolean","default":false,"description":"Whether to include inactive locations"}}}}}},"responses":{"200":{"description":"Successful response with redemption locations","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"locations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"street1":{"type":"string"},"street2":{"type":"string"},"city":{"type":"string"},"state":{"type":"string"},"postalCode":{"type":"string"},"active":{"type":"boolean"}}}},"count":{"type":"integer"}}}}}},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}},"get":{"summary":"Get redemption locations by tag IDs (query params)","description":"Retrieve redemption locations that match the specified tag IDs via query parameters","tags":["Redemption Locations"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"tagIds","required":true,"schema":{"type":"string"},"description":"Comma-separated list of tag IDs. Use \"ALL\" for all locations."},{"in":"query","name":"includeInactive","schema":{"type":"boolean","default":false},"description":"Whether to include inactive locations"}],"responses":{"200":{"description":"Successful response with redemption locations"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}}},"/api/refresh-token":{"post":{"summary":"Refresh authentication token","description":"Refreshes a user's JWT token using Supabase refresh token","tags":["Authentication"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["refreshToken"],"properties":{"refreshToken":{"type":"string","description":"Supabase refresh token"}}}}}},"responses":{"200":{"description":"Token refreshed successfully","content":{"application/json":{"schema":{"type":"object","properties":{"token":{"type":"string","description":"New access token"},"refreshToken":{"type":"string","description":"New refresh token"},"expiresAt":{"type":"number","description":"Token expiration timestamp"}}}}}},"400":{"description":"Bad request - refresh token is required"},"401":{"description":"Invalid or expired refresh token"},"500":{"description":"Internal server error"}}}},"/api/sign-image":{"post":{"summary":"Sign Cloudinary upload parameters","description":"Signs the provided parameters for secure Cloudinary uploads","tags":["Media"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"paramsToSign":{"type":"object","additionalProperties":{"type":"string"},"description":"Cloudinary parameters to be signed"}}}}}},"responses":{"200":{"description":"Successfully signed parameters","content":{"application/json":{"schema":{"type":"object","properties":{"signature":{"type":"string","description":"The generated signature for Cloudinary upload"}}}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/sign-up":{"post":{"summary":"Sign up a new user","description":"Two-step sign-up process - first sends OTP to phone, then verifies OTP and creates user profile","tags":["Authentication"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["phone"],"properties":{"phone":{"type":"string","description":"Phone number in E.164 format"},"code":{"type":"string","description":"OTP code (required in step 2)"},"email":{"type":"string","description":"User email address"},"firstName":{"type":"string","description":"User first name"},"lastName":{"type":"string","description":"User last name"},"username":{"type":"string","description":"Desired username"}}}}}},"responses":{"200":{"description":"OTP sent or user created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"token":{"type":"string","description":"JWT token (only in step 2)"},"userId":{"type":"string","description":"User ID (only in step 2)"}}}}}},"400":{"description":"Bad request - missing required fields or invalid data"},"500":{"description":"Internal server error"}}}},"/api/tags":{"get":{"summary":"Get available shop tags","description":"Retrieves active content tags for the current shop (widget filters / campaign targeting). Distinct from redemption-location tags.","tags":["Shop Tags"],"parameters":[{"in":"query","name":"search","schema":{"type":"string"},"description":"Optional search term to filter tags by name"},{"in":"query","name":"limit","schema":{"type":"integer","default":50},"description":"Maximum number of tags to return"}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"slug":{"type":"string"},"color":{"type":"string"},"icon":{"type":"string"}}}}}}},"500":{"description":"Server error"}}}},"/api/user/activity":{"get":{"summary":"Get user activity data","description":"Retrieves comprehensive activity data for the authenticated user including tasks, rewards, comments, and posts","tags":["User"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"tasksCompleted":{"type":"number"},"rewardsRedeemed":{"type":"number"},"savedOpportunities":{"type":"array","items":{"type":"string"}},"notifications":{"type":"array","items":{"type":"object"}},"comments":{"type":"array","items":{"type":"object"}},"posts":{"type":"array","items":{"type":"object"}}}}}}},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}}},"/api/user/claims/{claimId}":{"get":{"summary":"Retrieve a claim","description":"Get details of a specific claim for the authenticated user","tags":["Claims"],"parameters":[{"in":"path","name":"claimId","required":true,"schema":{"type":"string"},"description":"The ID of the claim to retrieve"}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimResponse"}}}},"404":{"description":"Claim not found or user not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"post":{"summary":"Submit a claim","description":"Submit or update a claim for the authenticated user","tags":["Claims"],"parameters":[{"in":"path","name":"claimId","required":true,"schema":{"type":"string"},"description":"The ID of the claim to submit or update"}],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"submitted":{"type":"boolean","description":"Indicates whether the claim is being submitted"}},"required":["submitted"]}}}}}},"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimResponse"}}}},"400":{"description":"Bad request or claim can't be submitted","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Claim not found or user not authenticated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/user/claims":{"post":{"summary":"Create a new claim","description":"Creates a new claim for the authenticated user for a specific opportunity.","tags":["Claims"],"parameters":[{"in":"header","name":"x-user-id","required":true,"schema":{"type":"string"},"description":"User ID for authentication"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["opportunityId"],"properties":{"opportunityId":{"type":"string","description":"ID of the opportunity to claim"},"opportunityLocationId":{"type":"string","description":"ID of the opportunity location (required if opportunity is location dependent)"}}}}}},"responses":{"200":{"description":"Existing claim returned","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimResponse"}}}},"201":{"description":"Claim created successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimResponse"}}}},"400":{"description":"Bad request (missing required fields)"},"401":{"description":"Unauthorized"},"404":{"description":"User, opportunity, or opportunity location not found"},"500":{"description":"Internal server error"}}}},"/api/user/coupons/{couponId}/redeem":{"post":{"summary":"Redeem a coupon for the authenticated user","tags":["Coupons"],"parameters":[{"in":"path","name":"couponId","required":true,"schema":{"type":"string"},"description":"The ID of the coupon to redeem"},{"in":"header","name":"x-user-id","required":true,"schema":{"type":"string"},"description":"The ID of the authenticated user"}],"responses":{"200":{"description":"Successfully redeemed the coupon","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CouponRedemptionResponse"}}}},"400":{"description":"Bad request (coupon expired, already redeemed, etc.)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"User or coupon not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/user/coupons/{couponId}/redemption-info":{"get":{"summary":"Get redemption location info for a coupon","description":"Returns whether the coupon has associated redemption locations and can be manually redeemed in-store","tags":["Coupons"],"parameters":[{"in":"path","name":"couponId","required":true,"schema":{"type":"string"}},{"in":"header","name":"x-user-id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Redemption info retrieved successfully"},"404":{"description":"Coupon not found"}}}},"/api/user/coupons/{couponId}":{"get":{"summary":"Get a specific coupon for the authenticated user","tags":["Coupons"],"parameters":[{"in":"path","name":"couponId","required":true,"schema":{"type":"string"},"description":"The ID of the coupon to retrieve"},{"in":"header","name":"x-user-id","required":true,"schema":{"type":"string"},"description":"The ID of the authenticated user"}],"responses":{"200":{"description":"Successfully retrieved the coupon","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Coupon"}}}},"404":{"description":"User or coupon not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/user/coupons":{"get":{"summary":"Retrieve all coupons for a user","description":"Fetches all coupons owned by the authenticated user","tags":["Coupons"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response with coupons","content":{"application/json":{"schema":{"type":"object","properties":{"coupons":{"type":"array","items":{"$ref":"#/components/schemas/Coupon"}}}}}}},"404":{"description":"User not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/user/opportunities/{opportunityId}":{"get":{"summary":"Get an opportunity by ID","tags":["Opportunities"],"parameters":[{"in":"path","name":"opportunityId","required":true,"schema":{"type":"string"},"description":"The ID of the opportunity to retrieve"}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Opportunity"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"404":{"description":"Opportunity not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}}}}},"/api/user/opportunities":{"get":{"summary":"Get active opportunities for a user","description":"Retrieves a list of active opportunities with associated tasks, surveys, and rewards.","tags":["Opportunities"],"parameters":[{"in":"header","name":"x-user-id","required":true,"schema":{"type":"string"},"description":"The ID of the user"},{"in":"query","name":"take","schema":{"type":"integer","default":20},"description":"Number of opportunities to return"},{"in":"query","name":"skip","schema":{"type":"integer","default":0},"description":"Number of opportunities to skip"},{"in":"query","name":"orderBy","schema":{"type":"string","enum":["asc","desc"],"default":"desc"},"description":"Order of opportunities by creation date"},{"in":"query","name":"tags","schema":{"type":"array"},"description":"Filter opportunities by tags name"}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"opportunities":{"type":"array","items":{"$ref":"#/components/schemas/Opportunity"}}}}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"404":{"description":"User not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}}}}},"/api/user/points/claim":{"post":{"summary":"Claim unclaimed point grants into spendable balance","description":"The explicit opt-in interface of the Point System. Converts the user's unclaimed point grants into spendable points via the points ledger (transaction type \"claim\"); the users.points balance is synced by a database trigger. XP (experience points, automatic rank progression) is NOT affected — it already accrued when the points were granted. Pass claimIds to claim specific grants, or omit the body to claim everything unclaimed.","tags":["User","Points"],"security":[{"bearerAuth":[]}],"requestBody":{"required":false,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimPointsRequest"}}}},"responses":{"200":{"description":"Claim processed","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ClaimPointsResponse"}}}},"400":{"description":"Invalid request body"},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}}},"/api/user/points/claimable":{"get":{"summary":"List the authenticated user's unclaimed point grants","description":"Part of the opt-in Point System. Points are granted as claimable rows when rewards are earned, but they do NOT become spendable balance until the user explicitly claims them via POST /api/user/points/claim. This is distinct from XP (experience points), which accrues automatically at earn time and drives rank/tier progression only. Returns the user's unclaimed grants ordered by creation time.","tags":["User","Points"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"claims":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"amount":{"type":"number"},"description":{"type":"string","nullable":true},"referenceType":{"type":"string","nullable":true},"referenceId":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","nullable":true,"description":"When the unclaimed grant lapses; null means it never expires"}}}},"totalPoints":{"type":"number","description":"Sum of all unclaimed point amounts"}}}}}},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}}},"/api/user/profile-pics":{"get":{"summary":"Get user profile pictures","description":"Retrieves profile pictures for all users in the shop","tags":["User"],"parameters":[{"in":"header","name":"x-user-id","required":true,"schema":{"type":"string"},"description":"User ID for authentication"}],"responses":{"200":{"description":"List of user profile pictures","content":{"application/json":{"schema":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string"},"profile_picture":{"type":"string"}}}}}}},"404":{"description":"User not found"},"500":{"description":"Internal server error"}}}},"/api/user/profile":{"get":{"summary":"Get user profile","description":"Retrieves the profile of the authenticated user. The response includes both `xp` (experience points — automatic rank progression, Rank System) and `points` (spendable reward balance — opt-in Point System that users must explicitly claim via /api/user/points/claim).","tags":["User"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Profile"}}}},"404":{"description":"User not found"},"500":{"description":"Internal server error"}}},"post":{"summary":"Update user profile","description":"Updates the profile of the authenticated user","tags":["User"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"username":{"type":"string","description":"Unique username for the user"},"firstName":{"type":"string","description":"User's first name"},"lastName":{"type":"string","description":"User's last name"},"middleName":{"type":"string","description":"User's middle name"},"profilePicture":{"type":"string","description":"URL of the user's profile picture"},"favoriteLocations":{"type":"array","items":{"type":"string"},"description":"Array of favorite location IDs"},"about":{"type":"string","description":"User's bio or description"},"email":{"type":"string","format":"email","description":"User's email address (triggers verification process if changed)"},"phoneNumber":{"type":"string","description":"User's phone number (triggers verification process if changed)"},"tfaMethods":{"type":"array","items":{"type":"string"},"description":"Array of two factor authentication methods (sms or email)"},"active":{"type":"boolean","description":"Account status. Set to false to disable the user's account, or true to re-enable it. While disabled, the UI informs the user that their access is pending approval from an admin."}}}}}},"responses":{"200":{"description":"Profile updated successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Profile"}}}},"202":{"description":"Email or phone number change request initiated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Profile"}}}},"400":{"description":"Bad request","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","enum":["Username already exists","Email already exists","Invalid email","Phone number already exists","active must be a boolean"]}}}}}},"404":{"description":"User not found","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}}}},"patch":{"summary":"Toggle a user's active flag","description":"Privileged, service-token-only endpoint that enables or disables a user's account by flipping the `active` flag. The target user is selected by exactly one unique identifier (id, email, phone, or username), scoped to the calling service token's shop. When `active` is provided it is applied verbatim; when omitted, the current value is toggled.","tags":["User"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"Provide exactly one of id, email, phone, or username to identify the target user.","properties":{"id":{"type":"string","format":"uuid","description":"The user's unique id"},"email":{"type":"string","format":"email","description":"The user's unique email address"},"phone":{"type":"string","description":"The user's unique phone number"},"username":{"type":"string","description":"The user's unique username"},"active":{"type":"boolean","description":"Desired account status. Omit to toggle the current value."}}}}}},"responses":{"200":{"description":"Active flag updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"active":{"type":"boolean"},"email":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"username":{"type":"string","nullable":true}}}}}},"400":{"description":"Bad request (missing/ambiguous identifier, or active not a boolean)"},"403":{"description":"Forbidden - a valid service token is required"},"404":{"description":"User not found"},"500":{"description":"Internal server error"}}}},"/api/user/redemption-locations":{"get":{"summary":"Get redemption locations","description":"Retrieve a list of active redemption locations, optionally sorted by distance from a given point.","tags":["Redemption Locations"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"page","schema":{"type":"integer","minimum":1,"default":1},"description":"Page number for pagination"},{"in":"query","name":"limit","schema":{"type":"integer","minimum":1,"maximum":100,"default":20},"description":"Number of items per page"},{"in":"query","name":"lat","schema":{"type":"number"},"description":"Latitude for location-based sorting (optional)"},{"in":"query","name":"lng","schema":{"type":"number"},"description":"Longitude for location-based sorting (optional)"}],"responses":{"200":{"description":"Successful response with redemption locations","content":{"application/json":{"schema":{"type":"object","properties":{"locations":{"type":"array","items":{"$ref":"#/components/schemas/RedemptionLocation"}},"totalCount":{"type":"integer"},"currentPage":{"type":"integer"},"totalPages":{"type":"integer"}}}}}},"400":{"description":"Invalid parameters","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/user/reward/{rewardId}":{"get":{"summary":"Retrieve a specific reward","description":"Get details of a specific reward including its associated opportunity","tags":["Rewards"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"rewardId","required":true,"schema":{"type":"string"},"description":"The ID of the reward to retrieve"}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Reward"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Reward not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/user/saved-opportunities":{"get":{"summary":"Get user's saved opportunities","description":"Retrieve a list of opportunities that the user has saved for later.","tags":["Saved Opportunities"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response with saved opportunities","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/Opportunity"}}}}},"401":{"description":"Unauthorized"},"500":{"description":"Internal server error"}}},"post":{"summary":"Save or unsave an opportunity","description":"Add or remove an opportunity from the user's saved list.","tags":["Saved Opportunities"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"opportunityId":{"type":"string","description":"The ID of the opportunity to save/unsave"},"action":{"type":"string","enum":["save","unsave"],"description":"Whether to save or unsave the opportunity"}},"required":["opportunityId","action"]}}}},"responses":{"200":{"description":"Opportunity saved/unsaved successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"},"savedOpportunities":{"type":"array","items":{"type":"string"}}}}}}},"400":{"description":"Invalid request"},"401":{"description":"Unauthorized"},"404":{"description":"Opportunity not found"},"500":{"description":"Internal server error"}}}},"/api/user/surveys/{surveyId}":{"get":{"summary":"Retrieve a specific survey","description":"Get details of a specific survey including its fields","tags":["Surveys"],"parameters":[{"in":"path","name":"surveyId","required":true,"schema":{"type":"string"},"description":"The ID of the survey to retrieve"}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Survey"}}}},"404":{"description":"Survey not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/user/tasks/{taskId}":{"get":{"summary":"Retrieve a specific task","description":"Get details of a specific task including its associated opportunity and survey","tags":["Tasks"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"taskId","required":true,"schema":{"type":"string"},"description":"The ID of the task to retrieve"}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskWithDetails"}}}},"401":{"description":"Unauthorized","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Task not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/user/tasks/{taskId}/survey":{"get":{"summary":"Retrieve a survey by task ID","description":"Get the survey details associated with a specific task ID.","tags":["Surveys"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"taskId","required":true,"schema":{"type":"string"},"description":"The ID of the task associated with the survey"}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Survey"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/user/username/{username}":{"get":{"summary":"Check username availability","description":"Checks if a given username is available or available. This endpoint is rate limited.","tags":["User"],"parameters":[{"in":"path","name":"username","required":true,"schema":{"type":"string"},"description":"The username to check"}],"responses":{"200":{"description":"Username is not available","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string","example":"Username is not available"}}}}}},"404":{"description":"Username is available","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Username is available"}}}}}},"429":{"description":"Too many requests","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Too many requests"}}}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Internal server error"}}}}}}}}},"/api/user/verifications/{verificationId}/history":{"get":{"summary":"Get verification history","description":"Get the history of all previous attempts for a task verification","tags":["Verifications"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"verificationId","required":true,"schema":{"type":"string"},"description":"The ID of the task verification"}],"responses":{"200":{"description":"Successfully retrieved verification history","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"history":{"type":"array","items":{"$ref":"#/components/schemas/TaskVerificationHistory"}}}}}}},"400":{"description":"Bad request (missing verification ID)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Verification not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/user/verifications/{verificationId}/retry":{"post":{"summary":"Retry a task verification","description":"Create a new attempt for a rejected task verification, moving the current attempt to history","tags":["Verifications"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"verificationId","required":true,"schema":{"type":"string"},"description":"The ID of the task verification to retry"}],"responses":{"200":{"description":"Successfully created new verification attempt","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"verification":{"$ref":"#/components/schemas/TaskVerification"},"message":{"type":"string"}}}}}},"400":{"description":"Bad request (missing verification ID or verification cannot be retried)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"Verification not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/user/verifications/{verificationId}":{"put":{"summary":"Update a task verification","description":"Update a task verification with new data including assets, survey responses, and location","tags":["Verifications"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"path","name":"verificationId","required":true,"schema":{"type":"string"},"description":"The ID of the task verification to update"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"assets":{"type":"array","items":{"$ref":"#/components/schemas/Asset"}},"surveyResponses":{"type":"array","items":{"$ref":"#/components/schemas/SurveyResponse"}},"text":{"type":"string"},"link":{"type":"string"},"lat":{"type":"number"},"long":{"type":"number"}}}}}},"responses":{"200":{"description":"Successfully updated task verification","content":{"application/json":{"schema":{"$ref":"#/components/schemas/TaskVerification"}}}},"400":{"description":"Bad request (missing verification ID)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"Task verification not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}}},"/api/user/verifications":{"post":{"summary":"Create a verification for a repeat task","description":"Creates a new verification attempt when a task requires multiple completions (`repeats` + `repeat_limit`). The claim must belong to the authenticated user and must not already be completed.\n","tags":["Verifications"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["claimId","taskId"],"properties":{"claimId":{"type":"string","format":"uuid"},"taskId":{"type":"string","format":"uuid"}}}}}},"responses":{"201":{"description":"Verification created","content":{"application/json":{"schema":{"type":"object","properties":{"verification":{"type":"object"}}}}}},"400":{"description":"Invalid request or task does not require repeats / pending exists / limit reached"},"401":{"description":"Unauthorized"},"404":{"description":"Claim or task not found"},"500":{"description":"Internal server error"}}}},"/api/user/vouchers/{voucherId}/redeem":{"post":{"summary":"Redeem a voucher for the authenticated user","tags":["Vouchers"],"parameters":[{"in":"path","name":"voucherId","required":true,"schema":{"type":"string"},"description":"The ID of the voucher to redeem"},{"in":"header","name":"x-user-id","required":true,"schema":{"type":"string"},"description":"The ID of the authenticated user"}],"responses":{"200":{"description":"Successfully redeemed the voucher","content":{"application/json":{"schema":{"$ref":"#/components/schemas/VoucherRedemptionResponse"}}}},"400":{"description":"Bad request (voucher expired, already redeemed, etc.)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"description":"User or voucher not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/user/vouchers/{voucherId}/redemption-info":{"get":{"summary":"Get redemption location info for a voucher","description":"Returns whether the voucher has associated redemption locations and can be manually redeemed in-store","tags":["Vouchers"],"parameters":[{"in":"path","name":"voucherId","required":true,"schema":{"type":"string"}},{"in":"header","name":"x-user-id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Redemption info retrieved successfully"},"404":{"description":"Voucher not found"}}}},"/api/user/vouchers/{voucherId}":{"get":{"summary":"Get a specific voucher for the authenticated user","tags":["Vouchers"],"parameters":[{"in":"path","name":"voucherId","required":true,"schema":{"type":"string"},"description":"The ID of the voucher to retrieve"}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successfully retrieved the voucher","content":{"application/json":{"schema":{"type":"object","properties":{"voucher":{"$ref":"#/components/schemas/Voucher"}}}}}},"404":{"description":"User or voucher not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/user/vouchers":{"get":{"summary":"Retrieve user's vouchers","description":"Fetches all vouchers associated with the authenticated user.","tags":["Vouchers"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response with vouchers","content":{"application/json":{"schema":{"type":"object","properties":{"vouchers":{"type":"array","items":{"$ref":"#/components/schemas/Voucher"}}}}}}},"404":{"description":"User not found","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/api/verification/{verificationId}/status":{"get":{"summary":"Get verification status","description":"Retrieves the current status of a task verification","tags":["Verifications"],"parameters":[{"in":"path","name":"verificationId","required":true,"schema":{"type":"string"},"description":"ID of the verification"},{"in":"query","name":"userId","schema":{"type":"string"},"description":"User ID (optional)"}],"responses":{"200":{"description":"Verification status","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["Pending","Approved","Rejected"]},"rejectionReason":{"type":"string","nullable":true}}}}}},"404":{"description":"Verification not found"},"500":{"description":"Internal server error"}}}},"/api/webhooks/claim-rewards/invoice-sync":{"post":{"summary":"Finalize points reservations from SOUL/Sage invoices (server-to-server)","description":"Called by the webhook-servers invoice sync with a batch of invoices observed in the SOUL (Sage) API. Each invoice is matched to a pending points reservation in the shop identified by `shopId` (falls back to `NEXT_PUBLIC_SHOP_NAME`) — first by `customerPONo` equal to the reservation code issued at redeem time, then (only when that PO is not a known reservation code) by resolved Legion user + line item SKU — and the matched reservation is finalized (`fulfilled`, invoice identifiers stamped for audit). Matching is quantity-aware: a reservation covers exactly one invoiced unit of its SKU, so the remaining units of each reward catalog line are settled in order — units already stamped on the invoice line by earlier runs, then pending user+SKU holds (one per unit), then one flat catalog deduction for the remainder. A fulfilled reservation code counts as one settled unit (no re-charge); an expired / cancelled code covers nothing — those units may still finalize a pending hold or flat-deduct. A line whose `quantityShipped` is explicitly zero is ignored. Flat deductions key on invoice number + line item code so multi-SKU invoices and replays do not double-charge; rejected/incomplete rows are resumed on retry. Also releases reservations whose hold window has lapsed. Authenticated with `Authorization: Bearer <CLAIM_REWARDS_WEBHOOK_SECRET>`.\n","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Batch processed; per-invoice results returned"},"400":{"description":"Invalid payload"},"401":{"description":"Unauthorized"},"500":{"description":"Could not process invoice batch"}}}},"/api/webhooks/claim-rewards/redeem":{"post":{"summary":"Commerce-platform redemption webhook (server-to-server)","description":"Lets the commerce platform keep Legion's points ledger as the source of truth for redemptions that happen outside the widget. `order.completed` fulfills an existing widget redemption (no second deduction) or, when no prior redemption exists, deducts points and records a fulfilled redemption. `order.cancelled`/`order.refunded` re-credits the points. Authenticated with `Authorization: Bearer <CLAIM_REWARDS_WEBHOOK_SECRET>`.\n","tags":["Webhooks"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Event processed"},"400":{"description":"Invalid or unsupported event payload"},"401":{"description":"Unauthorized"},"402":{"description":"Insufficient points"},"404":{"description":"Redemption not found"},"500":{"description":"Could not process redemption webhook"}}}},"/api/widget-presets/{id}":{"get":{"summary":"Get a specific widget preset","tags":["Widget Presets"],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Preset details"},"404":{"description":"Preset not found"}}},"patch":{"summary":"Update a widget preset","tags":["Widget Presets"],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string"},"config":{"type":"object"},"isDefault":{"type":"boolean"}}}}}},"responses":{"200":{"description":"Preset updated"},"404":{"description":"Preset not found"}}},"delete":{"summary":"Delete a widget preset","tags":["Widget Presets"],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Preset deleted"},"404":{"description":"Preset not found"}}}},"/api/widget-presets/{id}/set-default":{"post":{"summary":"Set a preset as the default for its widget type","description":"Marks this preset as the default and unsets any other default for the same widget type","tags":["Widget Presets"],"parameters":[{"in":"path","name":"id","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Preset set as default"},"401":{"description":"Unauthorized"},"404":{"description":"Preset not found"}}}},"/api/widget-presets":{"get":{"summary":"List user's widget presets","description":"Get all widget presets for the authenticated user, optionally filtered by widget type","tags":["Widget Presets"],"parameters":[{"in":"query","name":"widget_type","schema":{"type":"string"},"description":"Filter by widget type (e.g., 'qa', 'matrix', 'wallet')"}],"responses":{"200":{"description":"List of presets"},"401":{"description":"Unauthorized"}}},"post":{"summary":"Create a new widget preset","description":"Create a new customized widget preset for the authenticated user","tags":["Widget Presets"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["widgetType","name","config"],"properties":{"widgetType":{"type":"string"},"name":{"type":"string"},"config":{"type":"object"},"isDefault":{"type":"boolean"}}}}}},"responses":{"201":{"description":"Preset created successfully"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"}}}},"/api/widget/claim-rewards/balance":{"get":{"operationId":"getPointsBalance","summary":"Get the authenticated shopper's current points balance","description":"Returns the running points balance for the shopper resolved from the\nbearer token. Intended for partner-built Claim Rewards and Points History\nUIs that need to display the balance and gate redemptions. The balance is\nthe `balance` column of the shopper's most recent points transaction\n(or `0` when the shopper has no ledger rows yet).\n\nCompanion to `GET /api/widget/points-history`. DIY guide:\n`/docs/widgets/points-history-api`\n","tags":["Widget"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","required":["balance","currency","asOf"],"properties":{"balance":{"type":"number","description":"Current spendable points balance"},"currency":{"type":"string","example":"points"},"asOf":{"type":"string","format":"date-time","nullable":true,"description":"Timestamp of the most recent ledger row, if any"}}},"example":{"balance":1500,"currency":"points","asOf":"2026-06-15T22:14:03.000Z"}}}},"401":{"description":"Missing or invalid bearer token / unresolved shopper","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}},"404":{"description":"Pioneer widgets disabled (`PIONEER_WIDGETS` is not `1`)"},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"}}}}}}}}},"/api/widget/claim-rewards/redeem":{"post":{"summary":"Redeem a reward and deduct the shopper's points","description":"Deducts the reward's point cost from the authenticated shopper's balance and records a redemption. Legion is the source of truth for the balance: the point cost is re-fetched from the rewards catalog server-side so the client cannot under-pay. Returns the shopper's new balance and the reward `sku` to hand to the commerce platform's cart.\n","tags":["Widget"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["rewardId"],"properties":{"rewardId":{"type":"string","description":"Catalog reward id from GET /api/widget/claim-rewards/rewards."}}}}}},"responses":{"200":{"description":"Reward redeemed; points deducted.","content":{"application/json":{"schema":{"type":"object","properties":{"redemptionId":{"type":"string"},"transactionId":{"type":"string"},"balance":{"type":"number"},"currency":{"type":"string","example":"points"},"sku":{"type":"string","nullable":true},"reservationCode":{"type":"string","description":"Short code identifying this reservation. Stamp it on the commerce order (Sage customerPONo) so the invoice sync can finalize the reservation when the order is processed.\n"},"expiresAt":{"type":"string","format":"date-time","description":"When the unfulfilled reservation lapses and the points are refunded."}}}}}},"400":{"description":"Missing or invalid rewardId"},"401":{"description":"Unauthorized"},"402":{"description":"Insufficient points"},"404":{"description":"Reward not found or inactive"},"500":{"description":"Could not redeem reward"},"502":{"description":"Upstream rewards catalog rejected the request"},"503":{"description":"Rewards API is not configured"},"504":{"description":"Upstream rewards catalog timed out"}}}},"/api/widget/claim-rewards/rewards":{"get":{"summary":"List the active rewards catalog for the Claim Rewards widget","description":"Returns the active points-redemption rules as rewards, each carrying the `sku` a partner-built UI uses to add the reward to its commerce cart and a ready-to-render `imageUrl` (custom image, else built-in icon SVG, else a default gift icon). The upstream catalog is fetched server-side with a privileged key; the key is never exposed to the client. Rules without a usable SKU are filtered out.\n","tags":["Widget"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"active","schema":{"type":"string","default":"true"},"description":"Only return active redemption rules."},{"in":"query","name":"page","schema":{"type":"string","default":"1"},"description":"Page number."},{"in":"query","name":"limit","schema":{"type":"string","default":"200"},"description":"Page size."},{"in":"query","name":"search","schema":{"type":"string"},"description":"Optional reward-name filter."}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"rewards":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"pointsLabel":{"type":"string"},"sku":{"type":"string"},"imageUrl":{"type":"string","nullable":true,"description":"Ready-to-render reward image (custom upload, built-in icon SVG, or default). Loadable without auth.\n"},"icon":{"type":"string","enum":["cash","gift","service","truck","plane","cruise"]}}}},"pagination":{"type":"object","nullable":true}}}}}},"401":{"description":"Unauthorized"},"500":{"description":"Could not load rewards catalog"},"502":{"description":"Upstream rewards catalog rejected the request"},"503":{"description":"Rewards API is not configured"},"504":{"description":"Upstream rewards catalog timed out"}}}},"/api/widget/points-history":{"get":{"operationId":"getPointsHistory","summary":"Get the authenticated shopper's paginated points history","description":"Returns the shopper's points transaction ledger resolved from the bearer\ntoken, newest first. This is the \"no-widget\" REST equivalent of the\nPoints History widget: partners can build their own history UI from the\nsame data the widget renders. Each entry mirrors a row of the shopper's\npoints ledger (`points_transactions`), scoped to the current shop.\n\n**Auth:** send `Authorization: Bearer {token}` (SSO or hub JWT). Middleware\nverifies the token and stamps `x-user-id` for the handler.\n\n**Availability:** requires `PIONEER_WIDGETS=1` on the deployment; otherwise\nthe route returns `404`.\n\nDIY guide: `/docs/widgets/points-history-api`\n","tags":["Widget"],"security":[{"bearerAuth":[]}],"parameters":[{"in":"query","name":"limit","schema":{"type":"integer","minimum":1,"maximum":100,"default":10},"description":"Maximum number of transactions to return (capped at 100)."},{"in":"query","name":"offset","schema":{"type":"integer","minimum":0,"default":0},"description":"Number of transactions to skip for pagination."}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","required":["data","pagination"],"properties":{"data":{"type":"array","items":{"type":"object","required":["id","amount","balance","transaction_type","created_at"],"properties":{"id":{"type":"string","format":"uuid","description":"Ledger row id"},"amount":{"type":"number","description":"Signed delta for this row. Positive for credits (earn/claim/refund); negative for debits (redeem).\n"},"balance":{"type":"number","description":"Running spendable balance after this row"},"transaction_type":{"type":"string","description":"How the balance moved. Common values: `earn`, `claim`, `redeem`, `adjustment`, `refund`, `reversal`.\n","example":"earn"},"description":{"type":"string","nullable":true,"description":"Human-readable label (e.g. order reference)"},"reference_type":{"type":"string","nullable":true,"description":"Source entity type when present (e.g. `order`, `point_claim` for claim rows).\n"},"created_at":{"type":"string","format":"date-time"}}}},"pagination":{"type":"object","required":["limit","offset","total"],"properties":{"limit":{"type":"integer"},"offset":{"type":"integer"},"total":{"type":"integer","description":"Total matching rows for the shopper"}}}}},"example":{"data":[{"id":"b1c2d3e4-5f67-8901-abcd-ef2345678901","amount":150,"balance":1500,"transaction_type":"earn","description":"Order","reference_type":"order","created_at":"2026-06-15T22:14:03.000Z"}],"pagination":{"limit":10,"offset":0,"total":87}}}}},"401":{"description":"Missing or invalid bearer token / unresolved shopper","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Unauthorized - User ID not found in headers"}}}}}},"404":{"description":"Pioneer widgets disabled (`PIONEER_WIDGETS` is not `1`)"},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Could not load points history"}}}}}}}}},"/api/widget/profile-edit":{"get":{"summary":"Get user profile for profile-edit widget","description":"Retrieves the profile of the authenticated user for profile editing widget display","tags":["Widget"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string","nullable":true},"firstName":{"type":"string","nullable":true},"lastName":{"type":"string","nullable":true},"middleName":{"type":"string","nullable":true},"email":{"type":"string","nullable":true},"phone":{"type":"string","nullable":true},"profilePicture":{"type":"string","nullable":true},"about":{"type":"string","nullable":true},"emailNotifications":{"type":"boolean"},"smsNotifications":{"type":"boolean"},"mobilePushNotifications":{"type":"boolean"},"locationChecking":{"type":"boolean"}}}}}},"401":{"description":"Unauthorized"},"404":{"description":"User not found"},"500":{"description":"Internal server error"}}},"post":{"summary":"Update user profile via profile-edit widget","description":"Updates the profile of the authenticated user via the profile-edit widget","tags":["Widget"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"object","properties":{"username":{"type":"string"},"firstName":{"type":"string"},"lastName":{"type":"string"},"middleName":{"type":"string"},"email":{"type":"string"},"phone":{"type":"string"},"profilePicture":{"type":"string"},"about":{"type":"string"},"emailNotifications":{"type":"boolean"},"smsNotifications":{"type":"boolean"},"mobilePushNotifications":{"type":"boolean"},"locationChecking":{"type":"boolean"}}}}}}}},"responses":{"200":{"description":"Profile updated successfully"},"202":{"description":"Email or phone number change request initiated"},"400":{"description":"Bad request"},"401":{"description":"Unauthorized"},"404":{"description":"User not found"},"500":{"description":"Internal server error"}}}},"/api/widget/profile":{"get":{"summary":"Get user profile for widget","description":"Retrieves the profile of the authenticated user for widget display","tags":["Widget"],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Successful response","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"username":{"type":"string","nullable":true},"firstName":{"type":"string","nullable":true},"lastName":{"type":"string","nullable":true},"email":{"type":"string","nullable":true},"profilePicture":{"type":"string","nullable":true},"about":{"type":"string","nullable":true},"couponsCount":{"type":"number"},"vouchersCount":{"type":"number"}}}}}},"401":{"description":"Unauthorized"},"404":{"description":"User not found"},"500":{"description":"Internal server error"}}}}},"tags":[{"name":"Authentication","description":"Login, sign-up, token refresh, and session verification."},{"name":"SSO","description":"Shop-scoped single sign-on for embedded widgets and partner sites."},{"name":"User","description":"Profile, username lookup, activity summary, and account data."},{"name":"Points","description":"Opt-in Point System: list unclaimed grants and claim them into spendable balance (distinct from XP)."},{"name":"Opportunities","description":"Browse and fetch opportunities available to the user."},{"name":"Saved Opportunities","description":"Save and unsave opportunities for later."},{"name":"Campaigns","description":"Public campaign catalog and campaign detail for widgets and storefronts."},{"name":"Campaign Tracking","description":"Anonymous view/click counters for public campaigns (rate-limited)."},{"name":"Tasks","description":"Task details within an opportunity claim."},{"name":"Verifications","description":"Submit, retry, and inspect task verification status/history."},{"name":"Surveys","description":"Survey definitions attached to tasks and opportunities."},{"name":"Claims","description":"Create and submit opportunity claims."},{"name":"Rewards","description":"Reward prototypes and reward lookup for a claim."},{"name":"Coupons","description":"List, inspect, and redeem coupons."},{"name":"Vouchers","description":"List, inspect, and redeem vouchers."},{"name":"Redemption","description":"Legacy redemption helpers for coupons and vouchers."},{"name":"Redemption Locations","description":"Locations where rewards can be redeemed; filter by location tags."},{"name":"Shop Tags","description":"Shop content tags used for widget filters and campaign targeting (not redemption-location tags)."},{"name":"Social","description":"Posts and reactions on the social feed."},{"name":"Community","description":"Community posts and comments."},{"name":"Comments","description":"Comment creation and related moderation actions."},{"name":"Moderation","description":"Report and moderation flows for user-generated content."},{"name":"Activity Feed","description":"Public activity and feed polling endpoints."},{"name":"Notifications","description":"User notification inbox."},{"name":"Presence","description":"Online presence heartbeat and visitor counts."},{"name":"Widget","description":"Runtime widget APIs (profile, points history, claim rewards)."},{"name":"Widget Presets","description":"CRUD for saved widget configuration presets."},{"name":"Webhooks","description":"Inbound webhook receivers for claim-rewards integrations."},{"name":"Analytics","description":"Survey sentiment and related analytics reads."},{"name":"Media","description":"Signed image upload helpers."}],"x-tagGroups":[{"name":"Authentication","tags":["Authentication","SSO"]},{"name":"User & Points","tags":["User","Points"]},{"name":"Opportunities & Campaigns","tags":["Opportunities","Saved Opportunities","Campaigns","Campaign Tracking"]},{"name":"Tasks & Verifications","tags":["Tasks","Verifications","Surveys"]},{"name":"Rewards & Redemption","tags":["Claims","Rewards","Coupons","Vouchers","Redemption"]},{"name":"Locations & Tags","tags":["Redemption Locations","Shop Tags"]},{"name":"Social & Community","tags":["Social","Community","Comments","Moderation"]},{"name":"Activity & Engagement","tags":["Activity Feed","Notifications","Presence"]},{"name":"Widgets","tags":["Widget","Widget Presets"]},{"name":"Integrations","tags":["Webhooks","Analytics","Media"]}]}