diff --git a/.agents/skills/api-authentication/SKILL.md b/.agents/skills/api-authentication/SKILL.md new file mode 100644 index 00000000..d616f405 --- /dev/null +++ b/.agents/skills/api-authentication/SKILL.md @@ -0,0 +1,343 @@ +--- +name: api-authentication +description: Implement secure API authentication with JWT, OAuth 2.0, API keys, and session management. Use when securing APIs, managing tokens, or implementing user authentication flows. +--- + +# API Authentication + +## Overview + +Implement comprehensive authentication strategies for APIs including JWT tokens, OAuth 2.0, API keys, and session management with proper security practices. + +## When to Use + +- Securing API endpoints +- Implementing user login/logout flows +- Managing access tokens and refresh tokens +- Integrating OAuth 2.0 providers +- Protecting sensitive data +- Implementing API key authentication + +## Instructions + +### 1. **JWT Authentication** + +```javascript +// Node.js JWT Implementation +const express = require('express'); +const jwt = require('jsonwebtoken'); +const bcrypt = require('bcrypt'); + +const app = express(); +const SECRET_KEY = process.env.JWT_SECRET || 'your-secret-key'; +const REFRESH_SECRET = process.env.REFRESH_SECRET || 'your-refresh-secret'; + +// User login endpoint +app.post('/api/auth/login', async (req, res) => { + try { + const { email, password } = req.body; + + // Find user in database + const user = await User.findOne({ email }); + if (!user) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + // Verify password + const isValid = await bcrypt.compare(password, user.password); + if (!isValid) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + // Generate tokens + const accessToken = jwt.sign( + { userId: user.id, email: user.email, role: user.role }, + SECRET_KEY, + { expiresIn: '15m' } + ); + + const refreshToken = jwt.sign( + { userId: user.id }, + REFRESH_SECRET, + { expiresIn: '7d' } + ); + + // Store refresh token in database + await RefreshToken.create({ token: refreshToken, userId: user.id }); + + res.json({ + accessToken, + refreshToken, + expiresIn: 900, + user: { id: user.id, email: user.email, role: user.role } + }); + } catch (error) { + res.status(500).json({ error: 'Authentication failed' }); + } +}); + +// Refresh token endpoint +app.post('/api/auth/refresh', (req, res) => { + const { refreshToken } = req.body; + + if (!refreshToken) { + return res.status(401).json({ error: 'Refresh token required' }); + } + + try { + const decoded = jwt.verify(refreshToken, REFRESH_SECRET); + + // Verify token exists in database + const storedToken = await RefreshToken.findOne({ + token: refreshToken, + userId: decoded.userId + }); + + if (!storedToken) { + return res.status(401).json({ error: 'Invalid refresh token' }); + } + + // Generate new access token + const newAccessToken = jwt.sign( + { userId: decoded.userId }, + SECRET_KEY, + { expiresIn: '15m' } + ); + + res.json({ accessToken: newAccessToken, expiresIn: 900 }); + } catch (error) { + res.status(401).json({ error: 'Invalid refresh token' }); + } +}); + +// Middleware to verify JWT +const verifyToken = (req, res, next) => { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; // Bearer token + + if (!token) { + return res.status(401).json({ error: 'Access token required' }); + } + + try { + const decoded = jwt.verify(token, SECRET_KEY); + req.user = decoded; + next(); + } catch (error) { + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' }); + } + res.status(403).json({ error: 'Invalid token' }); + } +}; + +// Protected endpoint +app.get('/api/profile', verifyToken, (req, res) => { + res.json({ user: req.user }); +}); + +// Logout endpoint +app.post('/api/auth/logout', verifyToken, async (req, res) => { + try { + await RefreshToken.deleteOne({ userId: req.user.userId }); + res.json({ message: 'Logged out successfully' }); + } catch (error) { + res.status(500).json({ error: 'Logout failed' }); + } +}); +``` + +### 2. **OAuth 2.0 Implementation** + +```javascript +const passport = require('passport'); +const GoogleStrategy = require('passport-google-oauth20').Strategy; + +passport.use(new GoogleStrategy( + { + clientID: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + callbackURL: '/api/auth/google/callback' + }, + async (accessToken, refreshToken, profile, done) => { + try { + let user = await User.findOne({ googleId: profile.id }); + + if (!user) { + user = await User.create({ + googleId: profile.id, + email: profile.emails[0].value, + firstName: profile.name.givenName, + lastName: profile.name.familyName + }); + } + + return done(null, user); + } catch (error) { + return done(error); + } + } +)); + +// OAuth routes +app.get('/api/auth/google', + passport.authenticate('google', { scope: ['profile', 'email'] }) +); + +app.get('/api/auth/google/callback', + passport.authenticate('google', { failureRedirect: '/login' }), + (req, res) => { + const token = jwt.sign( + { userId: req.user.id, email: req.user.email }, + SECRET_KEY, + { expiresIn: '7d' } + ); + res.redirect(`/dashboard?token=${token}`); + } +); +``` + +### 3. **API Key Authentication** + +```javascript +// API Key middleware +const verifyApiKey = (req, res, next) => { + const apiKey = req.headers['x-api-key']; + + if (!apiKey) { + return res.status(401).json({ error: 'API key required' }); + } + + try { + // Verify API key format and existence + const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); + const apiKeyRecord = await ApiKey.findOne({ key_hash: keyHash, active: true }); + + if (!apiKeyRecord) { + return res.status(401).json({ error: 'Invalid API key' }); + } + + req.apiKey = apiKeyRecord; + next(); + } catch (error) { + res.status(500).json({ error: 'Authentication failed' }); + } +}; + +// Generate API key endpoint +app.post('/api/apikeys/generate', verifyToken, async (req, res) => { + try { + const apiKey = crypto.randomBytes(32).toString('hex'); + const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex'); + + const record = await ApiKey.create({ + userId: req.user.userId, + key_hash: keyHash, + name: req.body.name, + active: true + }); + + res.json({ apiKey, message: 'Save this key securely' }); + } catch (error) { + res.status(500).json({ error: 'Failed to generate API key' }); + } +}); + +// Protected endpoint with API key +app.get('/api/data', verifyApiKey, (req, res) => { + res.json({ data: 'sensitive data for API key holder' }); +}); +``` + +### 4. **Python Authentication Implementation** + +```python +from flask import Flask, request, jsonify +from flask_jwt_extended import JWTManager, create_access_token, jwt_required +from werkzeug.security import generate_password_hash, check_password_hash +from functools import wraps + +app = Flask(__name__) +app.config['JWT_SECRET_KEY'] = 'secret-key' +jwt = JWTManager(app) + +@app.route('/api/auth/login', methods=['POST']) +def login(): + data = request.get_json() + user = User.query.filter_by(email=data['email']).first() + + if not user or not check_password_hash(user.password, data['password']): + return jsonify({'error': 'Invalid credentials'}), 401 + + access_token = create_access_token( + identity=user.id, + additional_claims={'email': user.email, 'role': user.role} + ) + + return jsonify({ + 'accessToken': access_token, + 'user': {'id': user.id, 'email': user.email} + }), 200 + +@app.route('/api/protected', methods=['GET']) +@jwt_required() +def protected(): + from flask_jwt_extended import get_jwt_identity + user_id = get_jwt_identity() + return jsonify({'userId': user_id}), 200 + +def require_role(role): + def decorator(fn): + @wraps(fn) + @jwt_required() + def wrapper(*args, **kwargs): + from flask_jwt_extended import get_jwt + claims = get_jwt() + if claims.get('role') != role: + return jsonify({'error': 'Forbidden'}), 403 + return fn(*args, **kwargs) + return wrapper + return decorator + +@app.route('/api/admin', methods=['GET']) +@require_role('admin') +def admin_endpoint(): + return jsonify({'message': 'Admin data'}), 200 +``` + +## Best Practices + +### ✅ DO +- Use HTTPS for all authentication +- Store tokens securely (HttpOnly cookies) +- Implement token refresh mechanism +- Set appropriate token expiration times +- Hash and salt passwords +- Use strong secret keys +- Validate tokens on every request +- Implement rate limiting on auth endpoints +- Log authentication attempts +- Rotate secrets regularly + +### ❌ DON'T +- Store passwords in plain text +- Send tokens in URL parameters +- Use weak secret keys +- Store sensitive data in JWT payload +- Ignore token expiration +- Disable HTTPS in production +- Log sensitive tokens +- Reuse API keys across services +- Store credentials in code + +## Security Headers + +```javascript +app.use((req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); + next(); +}); +``` diff --git a/.agents/skills/api-contract-testing/SKILL.md b/.agents/skills/api-contract-testing/SKILL.md new file mode 100644 index 00000000..ba79cd94 --- /dev/null +++ b/.agents/skills/api-contract-testing/SKILL.md @@ -0,0 +1,624 @@ +--- +name: api-contract-testing +description: Verify API contracts between services to ensure compatibility and prevent breaking changes. Use for contract testing, Pact, API contract validation, schema validation, and consumer-driven contracts. +--- + +# API Contract Testing + +## Overview + +Contract testing verifies that APIs honor their contracts between consumers and providers. It ensures that service changes don't break dependent consumers without requiring full integration tests. Contract tests validate request/response formats, data types, and API behavior independently. + +## When to Use + +- Testing microservices communication +- Preventing breaking API changes +- Validating API versioning +- Testing consumer-provider contracts +- Ensuring backward compatibility +- Validating OpenAPI/Swagger specifications +- Testing third-party API integrations +- Catching contract violations in CI + +## Key Concepts + +- **Consumer**: Service that calls an API +- **Provider**: Service that exposes the API +- **Contract**: Agreement on API request/response format +- **Pact**: Consumer-defined expectations +- **Schema**: Structure definition (OpenAPI, JSON Schema) +- **Stub**: Generated mock from contract +- **Broker**: Central repository for contracts + +## Instructions + +### 1. **Pact for Consumer-Driven Contracts** + +#### Consumer Test (Jest/Pact) +```typescript +// tests/pact/user-service.pact.test.ts +import { PactV3, MatchersV3 } from '@pact-foundation/pact'; +import { UserService } from '../../src/services/UserService'; + +const { like, eachLike, iso8601DateTimeWithMillis } = MatchersV3; + +const provider = new PactV3({ + consumer: 'OrderService', + provider: 'UserService', + port: 1234, + dir: './pacts', +}); + +describe('User Service Contract', () => { + const userService = new UserService('http://localhost:1234'); + + describe('GET /users/:id', () => { + test('returns user when found', async () => { + await provider + .given('user with ID 123 exists') + .uponReceiving('a request for user 123') + .withRequest({ + method: 'GET', + path: '/users/123', + headers: { + Authorization: like('Bearer token'), + }, + }) + .willRespondWith({ + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + body: { + id: like('123'), + email: like('user@example.com'), + name: like('John Doe'), + age: like(30), + createdAt: iso8601DateTimeWithMillis('2024-01-01T00:00:00.000Z'), + role: like('user'), + }, + }) + .executeTest(async (mockServer) => { + const user = await userService.getUser('123'); + + expect(user.id).toBe('123'); + expect(user.email).toBeDefined(); + expect(user.name).toBeDefined(); + }); + }); + + test('returns 404 when user not found', async () => { + await provider + .given('user with ID 999 does not exist') + .uponReceiving('a request for non-existent user') + .withRequest({ + method: 'GET', + path: '/users/999', + }) + .willRespondWith({ + status: 404, + headers: { + 'Content-Type': 'application/json', + }, + body: { + error: like('User not found'), + code: like('USER_NOT_FOUND'), + }, + }) + .executeTest(async (mockServer) => { + await expect(userService.getUser('999')).rejects.toThrow( + 'User not found' + ); + }); + }); + }); + + describe('POST /users', () => { + test('creates new user', async () => { + await provider + .given('user does not exist') + .uponReceiving('a request to create user') + .withRequest({ + method: 'POST', + path: '/users', + headers: { + 'Content-Type': 'application/json', + }, + body: { + email: like('newuser@example.com'), + name: like('New User'), + age: like(25), + }, + }) + .willRespondWith({ + status: 201, + headers: { + 'Content-Type': 'application/json', + }, + body: { + id: like('new-123'), + email: like('newuser@example.com'), + name: like('New User'), + age: like(25), + createdAt: iso8601DateTimeWithMillis(), + role: 'user', + }, + }) + .executeTest(async (mockServer) => { + const user = await userService.createUser({ + email: 'newuser@example.com', + name: 'New User', + age: 25, + }); + + expect(user.id).toBeDefined(); + expect(user.email).toBe('newuser@example.com'); + }); + }); + }); + + describe('GET /users/:id/orders', () => { + test('returns user orders', async () => { + await provider + .given('user 123 has orders') + .uponReceiving('a request for user orders') + .withRequest({ + method: 'GET', + path: '/users/123/orders', + query: { + limit: '10', + offset: '0', + }, + }) + .willRespondWith({ + status: 200, + body: { + orders: eachLike({ + id: like('order-1'), + total: like(99.99), + status: like('completed'), + createdAt: iso8601DateTimeWithMillis(), + }), + total: like(5), + hasMore: like(false), + }, + }) + .executeTest(async (mockServer) => { + const response = await userService.getUserOrders('123', { + limit: 10, + offset: 0, + }); + + expect(response.orders).toBeDefined(); + expect(Array.isArray(response.orders)).toBe(true); + expect(response.total).toBeDefined(); + }); + }); + }); +}); +``` + +#### Provider Test (Verify Contract) +```typescript +// tests/pact/user-service.provider.test.ts +import { Verifier } from '@pact-foundation/pact'; +import path from 'path'; +import { app } from '../../src/app'; +import { setupTestDB, teardownTestDB } from '../helpers/db'; + +describe('Pact Provider Verification', () => { + let server; + + beforeAll(async () => { + await setupTestDB(); + server = app.listen(3001); + }); + + afterAll(async () => { + await teardownTestDB(); + server.close(); + }); + + test('validates the expectations of OrderService', () => { + return new Verifier({ + provider: 'UserService', + providerBaseUrl: 'http://localhost:3001', + pactUrls: [ + path.resolve(__dirname, '../../pacts/orderservice-userservice.json'), + ], + // Provider state setup + stateHandlers: { + 'user with ID 123 exists': async () => { + await createTestUser({ id: '123', name: 'John Doe' }); + }, + 'user with ID 999 does not exist': async () => { + await deleteUser('999'); + }, + 'user 123 has orders': async () => { + await createTestUser({ id: '123' }); + await createTestOrder({ userId: '123' }); + }, + }, + }) + .verifyProvider() + .then((output) => { + console.log('Pact Verification Complete!'); + }); + }); +}); +``` + +### 2. **OpenAPI Schema Validation** + +```typescript +// tests/contract/openapi.test.ts +import request from 'supertest'; +import { app } from '../../src/app'; +import OpenAPIValidator from 'express-openapi-validator'; +import fs from 'fs'; +import yaml from 'js-yaml'; + +describe('OpenAPI Contract Validation', () => { + let validator; + + beforeAll(() => { + const spec = yaml.load( + fs.readFileSync('./openapi.yaml', 'utf8') + ); + + validator = OpenAPIValidator.middleware({ + apiSpec: spec, + validateRequests: true, + validateResponses: true, + }); + }); + + test('GET /users/:id matches schema', async () => { + const response = await request(app) + .get('/users/123') + .expect(200); + + // Validate against OpenAPI schema + expect(response.body).toMatchObject({ + id: expect.any(String), + email: expect.stringMatching(/^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/), + name: expect.any(String), + age: expect.any(Number), + createdAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T/), + }); + }); + + test('POST /users validates request body', async () => { + const invalidUser = { + email: 'invalid-email', // Should fail validation + name: 'Test', + }; + + await request(app) + .post('/users') + .send(invalidUser) + .expect(400); + }); +}); +``` + +### 3. **JSON Schema Validation** + +```python +# tests/contract/test_schema_validation.py +import pytest +import jsonschema +from jsonschema import validate +import json + +# Define schemas +USER_SCHEMA = { + "type": "object", + "required": ["id", "email", "name"], + "properties": { + "id": {"type": "string"}, + "email": {"type": "string", "format": "email"}, + "name": {"type": "string"}, + "age": {"type": "integer", "minimum": 0, "maximum": 150}, + "role": {"type": "string", "enum": ["user", "admin"]}, + "createdAt": {"type": "string", "format": "date-time"}, + }, + "additionalProperties": False +} + +ORDER_SCHEMA = { + "type": "object", + "required": ["id", "userId", "total", "status"], + "properties": { + "id": {"type": "string"}, + "userId": {"type": "string"}, + "total": {"type": "number", "minimum": 0}, + "status": { + "type": "string", + "enum": ["pending", "paid", "shipped", "delivered", "cancelled"] + }, + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["productId", "quantity", "price"], + "properties": { + "productId": {"type": "string"}, + "quantity": {"type": "integer", "minimum": 1}, + "price": {"type": "number", "minimum": 0}, + } + } + } + } +} + +class TestAPIContracts: + def test_get_user_response_schema(self, api_client): + """Validate user endpoint response against schema.""" + response = api_client.get('/api/users/123') + + assert response.status_code == 200 + data = response.json() + + # Validate against schema + validate(instance=data, schema=USER_SCHEMA) + + def test_create_user_request_schema(self, api_client): + """Validate create user request body.""" + valid_user = { + "email": "test@example.com", + "name": "Test User", + "age": 30, + } + + response = api_client.post('/api/users', json=valid_user) + assert response.status_code == 201 + + # Response should also match schema + validate(instance=response.json(), schema=USER_SCHEMA) + + def test_invalid_request_rejected(self, api_client): + """Invalid requests should be rejected.""" + invalid_user = { + "email": "not-an-email", + "age": -5, # Invalid age + } + + response = api_client.post('/api/users', json=invalid_user) + assert response.status_code == 400 + + def test_order_response_schema(self, api_client): + """Validate order endpoint response.""" + response = api_client.get('/api/orders/order-123') + + assert response.status_code == 200 + validate(instance=response.json(), schema=ORDER_SCHEMA) + + def test_order_items_array_validation(self, api_client): + """Validate nested array schema.""" + order_data = { + "userId": "user-123", + "items": [ + {"productId": "prod-1", "quantity": 2, "price": 29.99}, + {"productId": "prod-2", "quantity": 1, "price": 49.99}, + ] + } + + response = api_client.post('/api/orders', json=order_data) + assert response.status_code == 201 + + result = response.json() + validate(instance=result, schema=ORDER_SCHEMA) +``` + +### 4. **REST Assured for Java** + +```java +// ContractTest.java +import io.restassured.RestAssured; +import io.restassured.module.jsv.JsonSchemaValidator; +import org.junit.jupiter.api.Test; +import static io.restassured.RestAssured.*; +import static org.hamcrest.Matchers.*; + +public class UserAPIContractTest { + + @Test + public void getUserShouldMatchSchema() { + given() + .pathParam("id", "123") + .when() + .get("/api/users/{id}") + .then() + .statusCode(200) + .body(JsonSchemaValidator.matchesJsonSchemaInClasspath("schemas/user-schema.json")) + .body("id", notNullValue()) + .body("email", matchesPattern("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$")) + .body("age", greaterThanOrEqualTo(0)); + } + + @Test + public void createUserShouldValidateRequest() { + String userJson = """ + { + "email": "test@example.com", + "name": "Test User", + "age": 30 + } + """; + + given() + .contentType("application/json") + .body(userJson) + .when() + .post("/api/users") + .then() + .statusCode(201) + .body("id", notNullValue()) + .body("email", equalTo("test@example.com")) + .body("createdAt", matchesPattern("\\d{4}-\\d{2}-\\d{2}T.*")); + } + + @Test + public void getUserOrdersShouldReturnArray() { + given() + .pathParam("id", "123") + .queryParam("limit", 10) + .when() + .get("/api/users/{id}/orders") + .then() + .statusCode(200) + .body("orders", isA(java.util.List.class)) + .body("orders[0].id", notNullValue()) + .body("orders[0].status", isIn(Arrays.asList( + "pending", "paid", "shipped", "delivered", "cancelled" + ))) + .body("total", greaterThanOrEqualTo(0)); + } + + @Test + public void invalidRequestShouldReturn400() { + String invalidUser = """ + { + "email": "not-an-email", + "age": -5 + } + """; + + given() + .contentType("application/json") + .body(invalidUser) + .when() + .post("/api/users") + .then() + .statusCode(400) + .body("error", notNullValue()); + } +} +``` + +### 5. **Contract Testing with Postman** + +```json +// postman-collection.json +{ + "info": { + "name": "User API Contract Tests" + }, + "item": [ + { + "name": "Get User", + "request": { + "method": "GET", + "url": "{{baseUrl}}/users/{{userId}}" + }, + "test": " + pm.test('Response status is 200', () => { + pm.response.to.have.status(200); + }); + + pm.test('Response matches schema', () => { + const schema = { + type: 'object', + required: ['id', 'email', 'name'], + properties: { + id: { type: 'string' }, + email: { type: 'string', format: 'email' }, + name: { type: 'string' }, + age: { type: 'integer' } + } + }; + + pm.response.to.have.jsonSchema(schema); + }); + + pm.test('Email format is valid', () => { + const data = pm.response.json(); + pm.expect(data.email).to.match(/^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$/); + }); + " + } + ] +} +``` + +### 6. **Pact Broker Integration** + +```yaml +# .github/workflows/contract-tests.yml +name: Contract Tests + +on: [push, pull_request] + +jobs: + consumer-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + + - run: npm ci + - run: npm run test:pact + + - name: Publish Pacts + run: | + npx pact-broker publish ./pacts \ + --consumer-app-version=${{ github.sha }} \ + --broker-base-url=${{ secrets.PACT_BROKER_URL }} \ + --broker-token=${{ secrets.PACT_BROKER_TOKEN }} + + provider-tests: + runs-on: ubuntu-latest + needs: consumer-tests + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + + - run: npm ci + - run: npm run test:pact:provider + + - name: Can I Deploy? + run: | + npx pact-broker can-i-deploy \ + --pacticipant=UserService \ + --version=${{ github.sha }} \ + --to-environment=production \ + --broker-base-url=${{ secrets.PACT_BROKER_URL }} \ + --broker-token=${{ secrets.PACT_BROKER_TOKEN }} +``` + +## Best Practices + +### ✅ DO +- Test contracts from consumer perspective +- Use matchers for flexible matching +- Validate schema structure, not specific values +- Version your contracts +- Test error responses +- Use Pact broker for contract sharing +- Run contract tests in CI +- Test backward compatibility + +### ❌ DON'T +- Test business logic in contract tests +- Hard-code specific values in contracts +- Skip error scenarios +- Test UI in contract tests +- Ignore contract versioning +- Deploy without contract verification +- Test implementation details +- Mock contract tests + +## Tools + +- **Pact**: Consumer-driven contracts (multiple languages) +- **Spring Cloud Contract**: JVM contract testing +- **OpenAPI/Swagger**: API specification and validation +- **Postman**: API contract testing +- **REST Assured**: Java API testing +- **Dredd**: OpenAPI/API Blueprint testing +- **Spectral**: OpenAPI linting + +## Examples + +See also: integration-testing, api-versioning-strategy, continuous-testing for comprehensive API testing strategies. diff --git a/.agents/skills/api-security-hardening/SKILL.md b/.agents/skills/api-security-hardening/SKILL.md new file mode 100644 index 00000000..d2a07b42 --- /dev/null +++ b/.agents/skills/api-security-hardening/SKILL.md @@ -0,0 +1,659 @@ +--- +name: api-security-hardening +description: Secure REST APIs with authentication, rate limiting, CORS, input validation, and security middleware. Use when building or hardening API endpoints against common attacks. +--- + +# API Security Hardening + +## Overview + +Implement comprehensive API security measures including authentication, authorization, rate limiting, input validation, and attack prevention to protect against common vulnerabilities. + +## When to Use + +- New API development +- Security audit remediation +- Production API hardening +- Compliance requirements +- High-traffic API protection +- Public API exposure + +## Implementation Examples + +### 1. **Node.js/Express API Security** + +```javascript +// secure-api.js - Comprehensive API security +const express = require('express'); +const helmet = require('helmet'); +const rateLimit = require('express-rate-limit'); +const mongoSanitize = require('express-mongo-sanitize'); +const xss = require('xss-clean'); +const hpp = require('hpp'); +const cors = require('cors'); +const jwt = require('jsonwebtoken'); +const validator = require('validator'); + +class SecureAPIServer { + constructor() { + this.app = express(); + this.setupSecurityMiddleware(); + this.setupRoutes(); + } + + setupSecurityMiddleware() { + // 1. Helmet - Set security headers + this.app.use(helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'"], + scriptSrc: ["'self'"], + imgSrc: ["'self'", "data:", "https:"] + } + }, + hsts: { + maxAge: 31536000, + includeSubDomains: true, + preload: true + } + })); + + // 2. CORS configuration + const corsOptions = { + origin: (origin, callback) => { + const whitelist = [ + 'https://example.com', + 'https://app.example.com' + ]; + + if (!origin || whitelist.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: true, + optionsSuccessStatus: 200, + methods: ['GET', 'POST', 'PUT', 'DELETE'], + allowedHeaders: ['Content-Type', 'Authorization'] + }; + + this.app.use(cors(corsOptions)); + + // 3. Rate limiting + const generalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 100, // limit each IP to 100 requests per windowMs + message: 'Too many requests from this IP', + standardHeaders: true, + legacyHeaders: false, + handler: (req, res) => { + res.status(429).json({ + error: 'rate_limit_exceeded', + message: 'Too many requests, please try again later', + retryAfter: req.rateLimit.resetTime + }); + } + }); + + const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + max: 5, // Stricter limit for auth endpoints + skipSuccessfulRequests: true + }); + + this.app.use('/api/', generalLimiter); + this.app.use('/api/auth/', authLimiter); + + // 4. Body parsing with size limits + this.app.use(express.json({ limit: '10kb' })); + this.app.use(express.urlencoded({ extended: true, limit: '10kb' })); + + // 5. NoSQL injection prevention + this.app.use(mongoSanitize()); + + // 6. XSS protection + this.app.use(xss()); + + // 7. HTTP Parameter Pollution prevention + this.app.use(hpp()); + + // 8. Request ID for tracking + this.app.use((req, res, next) => { + req.id = require('crypto').randomUUID(); + res.setHeader('X-Request-ID', req.id); + next(); + }); + + // 9. Security logging + this.app.use(this.securityLogger()); + } + + securityLogger() { + return (req, res, next) => { + const startTime = Date.now(); + + res.on('finish', () => { + const duration = Date.now() - startTime; + + const logEntry = { + timestamp: new Date().toISOString(), + requestId: req.id, + method: req.method, + path: req.path, + statusCode: res.statusCode, + duration, + ip: req.ip, + userAgent: req.get('user-agent') + }; + + // Log suspicious activity + if (res.statusCode === 401 || res.statusCode === 403) { + console.warn('Security event:', logEntry); + } + + if (res.statusCode >= 500) { + console.error('Server error:', logEntry); + } + }); + + next(); + }; + } + + // JWT authentication middleware + authenticateJWT() { + return (req, res, next) => { + const authHeader = req.headers.authorization; + + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'unauthorized', + message: 'Missing or invalid authorization header' + }); + } + + const token = authHeader.substring(7); + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET, { + algorithms: ['HS256'], + issuer: 'api.example.com', + audience: 'api.example.com' + }); + + req.user = decoded; + next(); + } catch (error) { + if (error.name === 'TokenExpiredError') { + return res.status(401).json({ + error: 'token_expired', + message: 'Token has expired' + }); + } + + return res.status(401).json({ + error: 'invalid_token', + message: 'Invalid token' + }); + } + }; + } + + // Input validation middleware + validateInput(schema) { + return (req, res, next) => { + const errors = []; + + // Validate request body + if (schema.body) { + for (const [field, rules] of Object.entries(schema.body)) { + const value = req.body[field]; + + if (rules.required && !value) { + errors.push(`${field} is required`); + continue; + } + + if (value) { + // Type validation + if (rules.type === 'email' && !validator.isEmail(value)) { + errors.push(`${field} must be a valid email`); + } + + if (rules.type === 'uuid' && !validator.isUUID(value)) { + errors.push(`${field} must be a valid UUID`); + } + + if (rules.type === 'url' && !validator.isURL(value)) { + errors.push(`${field} must be a valid URL`); + } + + // Length validation + if (rules.minLength && value.length < rules.minLength) { + errors.push(`${field} must be at least ${rules.minLength} characters`); + } + + if (rules.maxLength && value.length > rules.maxLength) { + errors.push(`${field} must be at most ${rules.maxLength} characters`); + } + + // Pattern validation + if (rules.pattern && !rules.pattern.test(value)) { + errors.push(`${field} format is invalid`); + } + } + } + } + + if (errors.length > 0) { + return res.status(400).json({ + error: 'validation_error', + message: 'Input validation failed', + details: errors + }); + } + + next(); + }; + } + + // Authorization middleware + authorize(...roles) { + return (req, res, next) => { + if (!req.user) { + return res.status(401).json({ + error: 'unauthorized', + message: 'Authentication required' + }); + } + + if (roles.length > 0 && !roles.includes(req.user.role)) { + return res.status(403).json({ + error: 'forbidden', + message: 'Insufficient permissions' + }); + } + + next(); + }; + } + + setupRoutes() { + // Public endpoint + this.app.get('/api/health', (req, res) => { + res.json({ status: 'healthy' }); + }); + + // Protected endpoint with validation + this.app.post('/api/users', + this.authenticateJWT(), + this.authorize('admin'), + this.validateInput({ + body: { + email: { required: true, type: 'email' }, + name: { required: true, minLength: 2, maxLength: 100 }, + password: { required: true, minLength: 8 } + } + }), + async (req, res) => { + try { + // Sanitized and validated input + const { email, name, password } = req.body; + + // Process request + res.status(201).json({ + message: 'User created successfully', + userId: '123' + }); + } catch (error) { + res.status(500).json({ + error: 'internal_error', + message: 'An error occurred' + }); + } + } + ); + + // Error handling middleware + this.app.use((err, req, res, next) => { + console.error('Unhandled error:', err); + + res.status(500).json({ + error: 'internal_error', + message: 'An unexpected error occurred', + requestId: req.id + }); + }); + } + + start(port = 3000) { + this.app.listen(port, () => { + console.log(`Secure API server running on port ${port}`); + }); + } +} + +// Usage +const server = new SecureAPIServer(); +server.start(3000); +``` + +### 2. **Python FastAPI Security** + +```python +# secure_api.py +from fastapi import FastAPI, HTTPException, Depends, Security, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.trustedhost import TrustedHostMiddleware +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded +from pydantic import BaseModel, EmailStr, validator, Field +import jwt +from datetime import datetime, timedelta +import re +from typing import Optional, List +import secrets + +app = FastAPI() +security = HTTPBearer() +limiter = Limiter(key_func=get_remote_address) + +# Rate limiting +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +# CORS configuration +app.add_middleware( + CORSMiddleware, + allow_origins=[ + "https://example.com", + "https://app.example.com" + ], + allow_credentials=True, + allow_methods=["GET", "POST", "PUT", "DELETE"], + allow_headers=["Content-Type", "Authorization"], + max_age=3600 +) + +# Trusted hosts +app.add_middleware( + TrustedHostMiddleware, + allowed_hosts=["example.com", "*.example.com"] +) + +# Security headers middleware +@app.middleware("http") +async def add_security_headers(request, call_next): + response = await call_next(request) + + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + response.headers["Content-Security-Policy"] = "default-src 'self'" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" + + return response + +# Input validation models +class CreateUserRequest(BaseModel): + email: EmailStr + name: str = Field(..., min_length=2, max_length=100) + password: str = Field(..., min_length=8) + + @validator('password') + def validate_password(cls, v): + if not re.search(r'[A-Z]', v): + raise ValueError('Password must contain uppercase letter') + if not re.search(r'[a-z]', v): + raise ValueError('Password must contain lowercase letter') + if not re.search(r'\d', v): + raise ValueError('Password must contain digit') + if not re.search(r'[!@#$%^&*]', v): + raise ValueError('Password must contain special character') + return v + + @validator('name') + def validate_name(cls, v): + # Prevent XSS in name field + if re.search(r'[<>]', v): + raise ValueError('Name contains invalid characters') + return v + +class APIKeyRequest(BaseModel): + name: str = Field(..., max_length=100) + expires_in_days: int = Field(30, ge=1, le=365) + +# JWT token verification +def verify_token(credentials: HTTPAuthorizationCredentials = Security(security)): + try: + token = credentials.credentials + + payload = jwt.decode( + token, + "your-secret-key", + algorithms=["HS256"], + audience="api.example.com", + issuer="api.example.com" + ) + + return payload + + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token has expired" + ) + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token" + ) + +# Role-based authorization +def require_role(required_roles: List[str]): + def role_checker(token_payload: dict = Depends(verify_token)): + user_role = token_payload.get('role') + + if user_role not in required_roles: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Insufficient permissions" + ) + + return token_payload + + return role_checker + +# API key authentication +def verify_api_key(api_key: str): + # Constant-time comparison to prevent timing attacks + if not secrets.compare_digest(api_key, "expected-api-key"): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid API key" + ) + return True + +# Endpoints +@app.get("/api/health") +@limiter.limit("100/minute") +async def health_check(): + return {"status": "healthy"} + +@app.post("/api/users") +@limiter.limit("10/minute") +async def create_user( + user: CreateUserRequest, + token_payload: dict = Depends(require_role(["admin"])) +): + """Create new user (admin only)""" + + # Hash password before storing + # hashed_password = bcrypt.hashpw(user.password.encode(), bcrypt.gensalt()) + + return { + "message": "User created successfully", + "user_id": "123" + } + +@app.post("/api/keys") +@limiter.limit("5/hour") +async def create_api_key( + request: APIKeyRequest, + token_payload: dict = Depends(verify_token) +): + """Generate API key""" + + # Generate secure random API key + api_key = secrets.token_urlsafe(32) + + expires_at = datetime.now() + timedelta(days=request.expires_in_days) + + return { + "api_key": api_key, + "expires_at": expires_at.isoformat(), + "name": request.name + } + +@app.get("/api/protected") +async def protected_endpoint(token_payload: dict = Depends(verify_token)): + return { + "message": "Access granted", + "user_id": token_payload.get("sub") + } + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000, ssl_certfile="cert.pem", ssl_keyfile="key.pem") +``` + +### 3. **API Gateway Security Configuration** + +```yaml +# nginx-api-gateway.conf +# Nginx API Gateway with security hardening + +http { + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'" always; + + # Rate limiting zones + limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=1r/s; + limit_conn_zone $binary_remote_addr zone=conn_limit:10m; + + # Request body size limit + client_max_body_size 10M; + client_body_buffer_size 128k; + + # Timeout settings + client_body_timeout 12; + client_header_timeout 12; + send_timeout 10; + + server { + listen 443 ssl http2; + server_name api.example.com; + + # SSL configuration + ssl_certificate /etc/ssl/certs/api.example.com.crt; + ssl_certificate_key /etc/ssl/private/api.example.com.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # API endpoints + location /api/ { + # Rate limiting + limit_req zone=api_limit burst=20 nodelay; + limit_conn conn_limit 10; + + # CORS headers + add_header Access-Control-Allow-Origin "https://app.example.com" always; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE" always; + add_header Access-Control-Allow-Headers "Authorization, Content-Type" always; + + # Block common exploits + if ($request_method !~ ^(GET|POST|PUT|DELETE|HEAD)$ ) { + return 444; + } + + # Proxy to backend + proxy_pass http://backend:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Timeouts + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # Auth endpoints with stricter limits + location /api/auth/ { + limit_req zone=auth_limit burst=5 nodelay; + + proxy_pass http://backend:3000; + } + + # Block access to sensitive files + location ~ /\. { + deny all; + return 404; + } + } +} +``` + +## Best Practices + +### ✅ DO +- Use HTTPS everywhere +- Implement rate limiting +- Validate all inputs +- Use security headers +- Log security events +- Implement CORS properly +- Use strong authentication +- Version your APIs + +### ❌ DON'T +- Expose stack traces +- Return detailed errors +- Trust user input +- Use HTTP for APIs +- Skip input validation +- Ignore rate limiting + +## Security Checklist + +- [ ] HTTPS enforced +- [ ] Authentication required +- [ ] Authorization implemented +- [ ] Rate limiting active +- [ ] Input validation +- [ ] CORS configured +- [ ] Security headers set +- [ ] Error handling secure +- [ ] Logging enabled +- [ ] API versioning + +## Resources + +- [OWASP API Security Top 10](https://owasp.org/www-project-api-security/) +- [API Security Best Practices](https://github.com/shieldfy/API-Security-Checklist) +- [JWT Best Practices](https://tools.ietf.org/html/rfc8725) diff --git a/.agents/skills/database-migration-management/SKILL.md b/.agents/skills/database-migration-management/SKILL.md new file mode 100644 index 00000000..6ea014a2 --- /dev/null +++ b/.agents/skills/database-migration-management/SKILL.md @@ -0,0 +1,384 @@ +--- +name: database-migration-management +description: Manage database migrations and schema versioning. Use when planning migrations, version control, rollback strategies, or data transformations in PostgreSQL and MySQL. +--- + +# Database Migration Management + +## Overview + +Implement robust database migration systems with version control, rollback capabilities, and data transformation strategies. Includes migration frameworks and production deployment patterns. + +## When to Use + +- Schema versioning and evolution +- Data transformations and cleanup +- Adding/removing tables and columns +- Index creation and optimization +- Migration testing and validation +- Rollback planning and execution +- Multi-environment deployments + +## Migration Framework Setup + +### PostgreSQL - Schema Versioning + +```sql +-- Create migrations tracking table +CREATE TABLE schema_migrations ( + version BIGINT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + duration_ms INTEGER, + checksum VARCHAR(64) +); + +-- Create migration log table +CREATE TABLE migration_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + version BIGINT NOT NULL, + status VARCHAR(20) NOT NULL, + error_message TEXT, + rolled_back_at TIMESTAMP, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Function to record migration +CREATE OR REPLACE FUNCTION record_migration( + p_version BIGINT, + p_name VARCHAR, + p_duration_ms INTEGER +) RETURNS void AS $$ +BEGIN + INSERT INTO schema_migrations (version, name, duration_ms) + VALUES (p_version, p_name, p_duration_ms) + ON CONFLICT (version) DO UPDATE + SET executed_at = CURRENT_TIMESTAMP; +END; +$$ LANGUAGE plpgsql; +``` + +### MySQL - Migration Tracking + +```sql +-- Create migrations table for MySQL +CREATE TABLE schema_migrations ( + version BIGINT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + duration_ms INT, + checksum VARCHAR(64) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Migration status table +CREATE TABLE migration_status ( + id INT AUTO_INCREMENT PRIMARY KEY, + version BIGINT NOT NULL, + status ENUM('pending', 'completed', 'failed', 'rolled_back'), + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +``` + +## Common Migration Patterns + +### Adding Columns + +**PostgreSQL - Safe Column Addition:** + +```sql +-- Migration: 20240115_001_add_phone_to_users.sql + +-- Add column with default (non-blocking) +ALTER TABLE users +ADD COLUMN phone VARCHAR(20) DEFAULT ''; + +-- Add constraint after population +ALTER TABLE users +ADD CONSTRAINT phone_format +CHECK (phone = '' OR phone ~ '^\+?[0-9\-\(\)]{10,}$'); + +-- Create index +CREATE INDEX CONCURRENTLY idx_users_phone ON users(phone); + +-- Rollback: +-- DROP INDEX CONCURRENTLY idx_users_phone; +-- ALTER TABLE users DROP COLUMN phone; +``` + +**MySQL - Column Addition:** + +```sql +-- Migration: 20240115_001_add_phone_to_users.sql + +-- Add column with ALTER +ALTER TABLE users +ADD COLUMN phone VARCHAR(20) DEFAULT '', +ADD INDEX idx_phone (phone); + +-- Rollback: +-- ALTER TABLE users DROP COLUMN phone; +``` + +### Renaming Columns + +**PostgreSQL - Column Rename:** + +```sql +-- Migration: 20240115_002_rename_user_name_columns.sql + +-- Rename columns +ALTER TABLE users RENAME COLUMN user_name TO full_name; +ALTER TABLE users RENAME COLUMN user_email TO email_address; + +-- Update indexes +REINDEX TABLE users; + +-- Rollback: +-- ALTER TABLE users RENAME COLUMN email_address TO user_email; +-- ALTER TABLE users RENAME COLUMN full_name TO user_name; +``` + +### Creating Indexes Non-blocking + +**PostgreSQL - Concurrent Index Creation:** + +```sql +-- Migration: 20240115_003_add_performance_indexes.sql + +-- Create indexes without blocking writes +CREATE INDEX CONCURRENTLY idx_orders_user_created +ON orders(user_id, created_at DESC); + +CREATE INDEX CONCURRENTLY idx_products_category_active +ON products(category_id) +WHERE active = true; + +-- Verify index creation +SELECT schemaname, tablename, indexname, idx_scan +FROM pg_stat_user_indexes +WHERE indexname LIKE 'idx_%'; + +-- Rollback: +-- DROP INDEX CONCURRENTLY idx_orders_user_created; +-- DROP INDEX CONCURRENTLY idx_products_category_active; +``` + +**MySQL - Online Index Creation:** + +```sql +-- Migration: 20240115_003_add_performance_indexes.sql + +-- Create indexes with ALGORITHM=INPLACE and LOCK=NONE +ALTER TABLE orders +ADD INDEX idx_user_created (user_id, created_at), +ALGORITHM=INPLACE, LOCK=NONE; + +-- Monitor progress +SELECT * FROM INFORMATION_SCHEMA.PROCESSLIST +WHERE INFO LIKE 'ALTER TABLE%'; +``` + +### Data Transformations + +**PostgreSQL - Data Cleanup Migration:** + +```sql +-- Migration: 20240115_004_normalize_email_addresses.sql + +-- Normalize existing email addresses +UPDATE users +SET email = LOWER(TRIM(email)) +WHERE email != LOWER(TRIM(email)); + +-- Remove duplicates by keeping latest +DELETE FROM users +WHERE id NOT IN ( + SELECT DISTINCT ON (LOWER(email)) id + FROM users + ORDER BY LOWER(email), created_at DESC +); + +-- Rollback: Restore from backup (no safe rollback for data changes) +``` + +**MySQL - Bulk Data Update:** + +```sql +-- Migration: 20240115_004_update_product_categories.sql + +-- Update multiple rows with JOIN +UPDATE products p +JOIN category_mapping cm ON p.old_category = cm.old_name +SET p.category_id = cm.new_category_id +WHERE p.old_category IS NOT NULL; + +-- Verify update +SELECT COUNT(*) as updated_count +FROM products +WHERE category_id IS NOT NULL; +``` + +### Table Structure Changes + +**PostgreSQL - Alter Table Migration:** + +```sql +-- Migration: 20240115_005_modify_order_columns.sql + +-- Add new column +ALTER TABLE orders +ADD COLUMN status_updated_at TIMESTAMP; + +-- Add constraint +ALTER TABLE orders +ADD CONSTRAINT valid_status +CHECK (status IN ('pending', 'processing', 'completed', 'cancelled')); + +-- Set default for existing records +UPDATE orders +SET status_updated_at = updated_at +WHERE status_updated_at IS NULL; + +-- Make column NOT NULL +ALTER TABLE orders +ALTER COLUMN status_updated_at SET NOT NULL; + +-- Rollback: +-- ALTER TABLE orders DROP COLUMN status_updated_at; +-- ALTER TABLE orders DROP CONSTRAINT valid_status; +``` + +## Testing Migrations + +**PostgreSQL - Test in Transaction:** + +```sql +-- Test migration in transaction (will be rolled back) +BEGIN; + +-- Run migration statements +ALTER TABLE users ADD COLUMN test_column VARCHAR(255); + +-- Validate data +SELECT COUNT(*) FROM users; +SELECT COUNT(DISTINCT email) FROM users; + +-- Rollback if issues found +ROLLBACK; + +-- Or commit if all good +COMMIT; +``` + +**Validate Migration:** + +```sql +-- Check migration was applied +SELECT version, name, executed_at FROM schema_migrations +WHERE version = 20240115005; + +-- Verify table structure +SELECT column_name, data_type, is_nullable +FROM information_schema.columns +WHERE table_name = 'users' +ORDER BY ordinal_position; +``` + +## Rollback Strategies + +**PostgreSQL - Bidirectional Migrations:** + +```sql +-- Migration file: 20240115_006_add_user_status.sql + +-- ===== UP ===== +CREATE TYPE user_status AS ENUM ('active', 'suspended', 'deleted'); +ALTER TABLE users ADD COLUMN status user_status DEFAULT 'active'; + +-- ===== DOWN ===== +-- ALTER TABLE users DROP COLUMN status; +-- DROP TYPE user_status; +``` + +**Rollback Execution:** + +```sql +-- Function to rollback to specific version +CREATE OR REPLACE FUNCTION rollback_to_version(p_target_version BIGINT) +RETURNS TABLE (version BIGINT, name VARCHAR, status VARCHAR) AS $$ +BEGIN + -- Execute down migrations in reverse order + RETURN QUERY + SELECT m.version, m.name, 'rolled_back'::VARCHAR + FROM schema_migrations m + WHERE m.version > p_target_version + ORDER BY m.version DESC; +END; +$$ LANGUAGE plpgsql; +``` + +## Production Deployment + +**Safe Migration Checklist:** + +- Test migration on production-like database +- Verify backup exists before migration +- Schedule during low-traffic window +- Monitor table locks and long-running queries +- Have rollback plan ready +- Test rollback procedure +- Document all changes +- Run in transaction when possible +- Verify data integrity after migration +- Update application code coordinated with migration + +**PostgreSQL - Long Transaction Safety:** + +```sql +-- Use statement timeout to prevent hanging migrations +SET statement_timeout = '30min'; + +-- Use lock timeout to prevent deadlocks +SET lock_timeout = '5min'; + +-- Run migration with timeouts +ALTER TABLE large_table +ADD COLUMN new_column VARCHAR(255), +ALGORITHM='INPLACE'; +``` + +## Migration Examples + +**Combined Migration - Multiple Changes:** + +```sql +-- Migration: 20240115_007_refactor_user_tables.sql + +BEGIN; + +-- 1. Create new column with data from old column +ALTER TABLE users ADD COLUMN full_name VARCHAR(255); +UPDATE users SET full_name = first_name || ' ' || last_name; + +-- 2. Add indexes +CREATE INDEX idx_users_full_name ON users(full_name); + +-- 3. Add new constraint +ALTER TABLE users +ADD CONSTRAINT email_unique UNIQUE(email); + +-- 4. Drop old columns (after verification) +-- ALTER TABLE users DROP COLUMN first_name; +-- ALTER TABLE users DROP COLUMN last_name; + +COMMIT; +``` + +## Resources + +- [Flyway - Java Migration Tool](https://flywaydb.org/) +- [Liquibase - Database Changelog](https://www.liquibase.org/) +- [Alembic - Python Migration](https://alembic.sqlalchemy.org/) +- [PostgreSQL ALTER TABLE](https://www.postgresql.org/docs/current/sql-altertable.html) +- [MySQL ALTER TABLE](https://dev.mysql.com/doc/refman/8.0/en/alter-table.html) diff --git a/.agents/skills/find-skills/SKILL.md b/.agents/skills/find-skills/SKILL.md new file mode 100644 index 00000000..c797184e --- /dev/null +++ b/.agents/skills/find-skills/SKILL.md @@ -0,0 +1,133 @@ +--- +name: find-skills +description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill. +--- + +# Find Skills + +This skill helps you discover and install skills from the open agent skills ecosystem. + +## When to Use This Skill + +Use this skill when the user: + +- Asks "how do I do X" where X might be a common task with an existing skill +- Says "find a skill for X" or "is there a skill for X" +- Asks "can you do X" where X is a specialized capability +- Expresses interest in extending agent capabilities +- Wants to search for tools, templates, or workflows +- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.) + +## What is the Skills CLI? + +The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools. + +**Key commands:** + +- `npx skills find [query]` - Search for skills interactively or by keyword +- `npx skills add ` - Install a skill from GitHub or other sources +- `npx skills check` - Check for skill updates +- `npx skills update` - Update all installed skills + +**Browse skills at:** https://skills.sh/ + +## How to Help Users Find Skills + +### Step 1: Understand What They Need + +When a user asks for help with something, identify: + +1. The domain (e.g., React, testing, design, deployment) +2. The specific task (e.g., writing tests, creating animations, reviewing PRs) +3. Whether this is a common enough task that a skill likely exists + +### Step 2: Search for Skills + +Run the find command with a relevant query: + +```bash +npx skills find [query] +``` + +For example: + +- User asks "how do I make my React app faster?" → `npx skills find react performance` +- User asks "can you help me with PR reviews?" → `npx skills find pr review` +- User asks "I need to create a changelog" → `npx skills find changelog` + +The command will return results like: + +``` +Install with npx skills add + +vercel-labs/agent-skills@vercel-react-best-practices +└ https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices +``` + +### Step 3: Present Options to the User + +When you find relevant skills, present them to the user with: + +1. The skill name and what it does +2. The install command they can run +3. A link to learn more at skills.sh + +Example response: + +``` +I found a skill that might help! The "vercel-react-best-practices" skill provides +React and Next.js performance optimization guidelines from Vercel Engineering. + +To install it: +npx skills add vercel-labs/agent-skills@vercel-react-best-practices + +Learn more: https://skills.sh/vercel-labs/agent-skills/vercel-react-best-practices +``` + +### Step 4: Offer to Install + +If the user wants to proceed, you can install the skill for them: + +```bash +npx skills add -g -y +``` + +The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts. + +## Common Skill Categories + +When searching, consider these common categories: + +| Category | Example Queries | +| --------------- | ---------------------------------------- | +| Web Development | react, nextjs, typescript, css, tailwind | +| Testing | testing, jest, playwright, e2e | +| DevOps | deploy, docker, kubernetes, ci-cd | +| Documentation | docs, readme, changelog, api-docs | +| Code Quality | review, lint, refactor, best-practices | +| Design | ui, ux, design-system, accessibility | +| Productivity | workflow, automation, git | + +## Tips for Effective Searches + +1. **Use specific keywords**: "react testing" is better than just "testing" +2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd" +3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills` + +## When No Skills Are Found + +If no relevant skills exist: + +1. Acknowledge that no existing skill was found +2. Offer to help with the task directly using your general capabilities +3. Suggest the user could create their own skill with `npx skills init` + +Example: + +``` +I searched for skills related to "xyz" but didn't find any matches. +I can still help you with this task directly! Would you like me to proceed? + +If this is something you do often, you could create your own skill: +npx skills init my-xyz-skill +``` diff --git a/.agents/skills/gcp-cloud-run/SKILL.md b/.agents/skills/gcp-cloud-run/SKILL.md new file mode 100644 index 00000000..c15feb80 --- /dev/null +++ b/.agents/skills/gcp-cloud-run/SKILL.md @@ -0,0 +1,292 @@ +--- +name: gcp-cloud-run +description: "Specialized skill for building production-ready serverless applications on GCP. Covers Cloud Run services (containerized), Cloud Run Functions (event-driven), cold start optimization, and event-dri..." +source: vibeship-spawner-skills (Apache 2.0) +risk: unknown +--- + +# GCP Cloud Run + +## Patterns + +### Cloud Run Service Pattern + +Containerized web service on Cloud Run + +**When to use**: ['Web applications and APIs', 'Need any runtime or library', 'Complex services with multiple endpoints', 'Stateless containerized workloads'] + +```javascript +```dockerfile +# Dockerfile - Multi-stage build for smaller image +FROM node:20-slim AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production + +FROM node:20-slim +WORKDIR /app + +# Copy only production dependencies +COPY --from=builder /app/node_modules ./node_modules +COPY src ./src +COPY package.json ./ + +# Cloud Run uses PORT env variable +ENV PORT=8080 +EXPOSE 8080 + +# Run as non-root user +USER node + +CMD ["node", "src/index.js"] +``` + +```javascript +// src/index.js +const express = require('express'); +const app = express(); + +app.use(express.json()); + +// Health check endpoint +app.get('/health', (req, res) => { + res.status(200).send('OK'); +}); + +// API routes +app.get('/api/items/:id', async (req, res) => { + try { + const item = await getItem(req.params.id); + res.json(item); + } catch (error) { + console.error('Error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// Graceful shutdown +process.on('SIGTERM', () => { + console.log('SIGTERM received, shutting down gracefully'); + server.close(() => { + console.log('Server closed'); + process.exit(0); + }); +}); + +const PORT = process.env.PORT || 8080; +const server = app.listen(PORT, () => { + console.log(`Server listening on port ${PORT}`); +}); +``` + +```yaml +# cloudbuild.yaml +steps: + # Build the container image + - name: 'gcr.io/cloud-builders/docker' + args: ['build', '-t', 'gcr.io/$PROJECT_ID/my-service:$COMMIT_SHA', '.'] + + # Push the container image + - name: 'gcr.io/cloud-builders/docker' + args: ['push', 'gcr.io/$PROJECT_ID/my-service:$COMMIT_SHA'] + + # Deploy to Cloud Run + - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk' + entrypoint: gcloud + args: + - 'run' + - 'deploy' + - 'my-service' + - '--image=gcr.io/$PROJECT_ID/my-service:$COMMIT_SHA' + - '--region=us-central1' + - '--platform=managed' + - '--allow-unauthenticated' + - '--memory=512Mi' + - '--cpu=1' + - '--min-instances=1' + - '--max-instances=100' + +``` + +### Cloud Run Functions Pattern + +Event-driven functions (formerly Cloud Functions) + +**When to use**: ['Simple event handlers', 'Pub/Sub message processing', 'Cloud Storage triggers', 'HTTP webhooks'] + +```javascript +```javascript +// HTTP Function +// index.js +const functions = require('@google-cloud/functions-framework'); + +functions.http('helloHttp', (req, res) => { + const name = req.query.name || req.body.name || 'World'; + res.send(`Hello, ${name}!`); +}); +``` + +```javascript +// Pub/Sub Function +const functions = require('@google-cloud/functions-framework'); + +functions.cloudEvent('processPubSub', (cloudEvent) => { + // Decode Pub/Sub message + const message = cloudEvent.data.message; + const data = message.data + ? JSON.parse(Buffer.from(message.data, 'base64').toString()) + : {}; + + console.log('Received message:', data); + + // Process message + processMessage(data); +}); +``` + +```javascript +// Cloud Storage Function +const functions = require('@google-cloud/functions-framework'); + +functions.cloudEvent('processStorageEvent', async (cloudEvent) => { + const file = cloudEvent.data; + + console.log(`Event: ${cloudEvent.type}`); + console.log(`Bucket: ${file.bucket}`); + console.log(`File: ${file.name}`); + + if (cloudEvent.type === 'google.cloud.storage.object.v1.finalized') { + await processUploadedFile(file.bucket, file.name); + } +}); +``` + +```bash +# Deploy HTTP function +gcloud functions deploy hello-http \ + --gen2 \ + --runtime nodejs20 \ + --trigger-http \ + --allow-unauthenticated \ + --region us-central1 + +# Deploy Pub/Sub function +gcloud functions deploy process-messages \ + --gen2 \ + --runtime nodejs20 \ + --trigger-topic my-topic \ + --region us-central1 + +# Deploy Cloud Storage function +gcloud functions deploy process-uploads \ + --gen2 \ + --runtime nodejs20 \ + --trigger-event-filters="type=google.cloud.storage.object.v1.finalized" \ + --trigger-event-filters="bucket=my-bucket" \ + --region us-central1 +``` +``` + +### Cold Start Optimization Pattern + +Minimize cold start latency for Cloud Run + +**When to use**: ['Latency-sensitive applications', 'User-facing APIs', 'High-traffic services'] + +```javascript +## 1. Enable Startup CPU Boost + +```bash +gcloud run deploy my-service \ + --cpu-boost \ + --region us-central1 +``` + +## 2. Set Minimum Instances + +```bash +gcloud run deploy my-service \ + --min-instances 1 \ + --region us-central1 +``` + +## 3. Optimize Container Image + +```dockerfile +# Use distroless for minimal image +FROM node:20-slim AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci --only=production + +FROM gcr.io/distroless/nodejs20-debian12 +WORKDIR /app +COPY --from=builder /app/node_modules ./node_modules +COPY src ./src +CMD ["src/index.js"] +``` + +## 4. Lazy Initialize Heavy Dependencies + +```javascript +// Lazy load heavy libraries +let bigQueryClient = null; + +function getBigQueryClient() { + if (!bigQueryClient) { + const { BigQuery } = require('@google-cloud/bigquery'); + bigQueryClient = new BigQuery(); + } + return bigQueryClient; +} + +// Only initialize when needed +app.get('/api/analytics', async (req, res) => { + const client = getBigQueryClient(); + const results = await client.query({...}); + res.json(results); +}); +``` + +## 5. Increase Memory (More CPU) + +```bash +# Higher memory = more CPU during startup +gcloud run deploy my-service \ + --memory 1Gi \ + --cpu 2 \ + --region us-central1 +``` +``` + +## Anti-Patterns + +### ❌ CPU-Intensive Work Without Concurrency=1 + +**Why bad**: CPU is shared across concurrent requests. CPU-bound work +will starve other requests, causing timeouts. + +### ❌ Writing Large Files to /tmp + +**Why bad**: /tmp is an in-memory filesystem. Large files consume +your memory allocation and can cause OOM errors. + +### ❌ Long-Running Background Tasks + +**Why bad**: Cloud Run throttles CPU to near-zero when not handling +requests. Background tasks will be extremely slow or stall. + +## ⚠️ Sharp Edges + +| Issue | Severity | Solution | +|-------|----------|----------| +| Issue | high | ## Calculate memory including /tmp usage | +| Issue | high | ## Set appropriate concurrency | +| Issue | high | ## Enable CPU always allocated | +| Issue | medium | ## Configure connection pool with keep-alive | +| Issue | high | ## Enable startup CPU boost | +| Issue | medium | ## Explicitly set execution environment | +| Issue | medium | ## Set consistent timeouts | + +## When to Use +This skill is applicable to execute the workflow or actions described in the overview. diff --git a/.claude/agents/ui-ux-design.md b/.claude/agents/ui-ux-design.md index 2128f64a..63398e13 100644 --- a/.claude/agents/ui-ux-design.md +++ b/.claude/agents/ui-ux-design.md @@ -37,6 +37,7 @@ Before any design work, ensure you have loaded: - `krow-mobile-design-system` — Colors, typography, icons, spacing, component patterns - `frontend-design` - `ui-ux-pro-max` +- `mobile-design` Load additional skills as needed for specific design challenges. diff --git a/.claude/skills/krow-paper-design/SKILL.md b/.claude/skills/krow-paper-design/SKILL.md index df9b2994..93860546 100644 --- a/.claude/skills/krow-paper-design/SKILL.md +++ b/.claude/skills/krow-paper-design/SKILL.md @@ -21,19 +21,42 @@ This skill defines the design token system, component patterns, screen structure ### Color Palette +**Primary:** | Token | Hex | Usage | |-------|-----|-------| -| Primary | `#0A39DF` | CTAs, active states, links, selected chips, nav active icons, pay rates | -| Foreground | `#121826` | Headings, primary text, dark UI elements | -| Text Secondary | `#6A7382` | Labels, captions, inactive nav, section headers, placeholder text, back chevrons | -| Secondary BG | `#F1F3F5` | Subtle backgrounds, dividers, map placeholders | -| Border | `#D1D5DB` | Card borders, unselected chip borders, outline button borders | -| Input Border | `#E2E8F0` | Text input borders (lighter than general border) | -| Destructive | `#F04444` | Error states, destructive actions (e.g., Request Swap) | | Background | `#FAFBFC` | Page/artboard background | -| Card BG | `#FFFFFF` | Card surfaces, input backgrounds | -| Success | `#059669` | Active status dot, checkmark icons, requirement met | -| Warning Amber | `#D97706` | Urgent/Pending badge text | +| Foreground | `#121826` | Headings, primary text, dark UI elements | +| Primary | `#0A39DF` | CTAs, active states, links, selected chips, nav active icons, pay rates | +| Primary Fg | `#F7FAFC` | Light foreground on primary surfaces | + +**Semantic:** +| Token | Hex | Usage | +|-------|-----|-------| +| Secondary | `#F1F3F5` | Subtle backgrounds, dividers, secondary button bg | +| Accent | `#F9E547` | Highlight, warning chip accents | +| Text Secondary | `#6A7382` | Labels, captions, inactive nav, section headers, back chevrons | +| Destructive | `#F04444` | Error states, destructive actions | + +**Border & Input:** +| Token | Hex | Usage | +|-------|-----|-------| +| Border | `#D1D5DB` | Card borders, unselected chip borders, outline button borders | +| Input | `#F5F6F8` | Text input background (read-only/disabled states) | + +**Status:** +| Token | Hex | Usage | +|-------|-----|-------| +| Success | `#10B981` | Accept buttons, active status, checkmarks | +| Info | `#0A39DF` | Informational badges (same as Primary) | +| Warning | `#D97706` | Urgent/Pending badge text | +| Neutral | `#94A3B8` | Disabled text, placeholder text | +| Danger | `#F04444` | Error badges, destructive (same as Destructive) | + +**Gradients:** +| Token | Definition | Usage | +|-------|-----------|-------| +| mobileHero | Foreground → Primary → Primary Fg | Hero sections, splash screens | +| adminHero | Primary → Success | Admin/dashboard hero cards | ### Semantic Badge Colors @@ -44,74 +67,121 @@ This skill defines the design token system, component patterns, screen structure | Pending | `#FEF9EE` | `#D97706` | | Urgent | `#FEF9EE` | `#D97706` | | One-Time | `#ECFDF5` | `#059669` | -| Recurring | `#EBF0FF` | `#0A39DF` (use `#EFF6FF` bg on detail pages) | +| Recurring | `#EFF6FF` | `#0A39DF` | ### Typography -| Style | Font | Size | Weight | Line Height | Usage | -|-------|------|------|--------|-------------|-------| -| Display | Inter Tight | 28px | 700 | 34px | Page titles (Find Shifts, My Shifts) | -| H1 | Inter Tight | 24px | 700 | 30px | Detail page titles (venue names) | -| H2 | Inter Tight | 20px | 700 | 26px | Section headings | -| H3 | Inter Tight | 18px | 700 | 22px | Card titles, schedule values | -| Body Large | Manrope | 16px | 600 | 20px | Button text, CTA labels | -| Body Default | Manrope | 14px | 400-500 | 18px | Body text, descriptions | -| Body Small | Manrope | 13px | 400-500 | 16px | Card metadata, time/pay info | -| Caption | Manrope | 12px | 500-600 | 16px | Small chip text, tab labels | -| Section Label | Manrope | 11px | 700 | 14px | Uppercase section headers (letter-spacing: 0.06em) | -| Badge Text | Manrope | 11px | 600-700 | 14px | Status badge labels (letter-spacing: 0.04em) | -| Nav Label | Manrope | 10px | 600 | 12px | Bottom nav labels | +**Inter Tight — Headings:** +| Style | Size | Weight | Letter Spacing | Line Height | Usage | +|-------|------|--------|---------------|-------------|-------| +| Display | 28px | 700 | -0.02em | 34px | Page titles (Find Shifts, My Shifts) | +| Heading 1 | 24px | 700 | -0.02em | 30px | Detail page titles (venue names) | +| Heading 2 | 20px | 700 | -0.01em | 26px | Section headings | +| Heading 3 | 18px | 700 | -0.01em | 22px | Card titles, schedule values | +| Heading 4 | 16px | 700 | — | 20px | Card titles (standard cards), sub-headings | -### Spacing +**Manrope — Body:** +| Style | Size | Weight | Line Height | Usage | +|-------|------|--------|-------------|-------| +| Body Large Regular | 16px | 400 | 20px | Long body text | +| Body Large Medium | 16px | 500 | 20px | Emphasized body text | +| Body Large Semibold | 16px | 600 | 20px | Strong labels, Full Width CTA text (15px) | +| Body Default | 14px | 400 | 18px | Body text, descriptions | +| Body Default Semibold | 14px | 600 | 18px | Button text, chip text, bold body | +| Caption | 12px | 400 | 16px | Small text, helper text, input labels | +| Overline Label | 11px | 600 | 14px | Uppercase section headers (letter-spacing: 0.06em) | +| Badge Text | 11px | 600-700 | 14px | Status badge labels (letter-spacing: 0.04em) | +| Nav Label | 10px | 600 | 12px | Bottom nav labels | + +### Spacing Scale | Token | Value | Usage | |-------|-------|-------| -| Page padding | 24px | Horizontal padding from screen edge | -| Section gap | 16-24px | Between major content sections | -| Group gap | 8-12px | Within a section (e.g., label to input) | -| Element gap | 4px | Tight spacing (e.g., subtitle under title) | -| Bottom safe area | 40px | Padding below last element / CTA | +| xs | 4px | Tight spacing (subtitle under title) | +| sm | 8px | Element gap within groups | +| md | 12px | Group gap (label to input) | +| lg | 16px | Card padding, medium section gap | +| xl | 24px | Page margins, section gap | +| 2xl | 32px | Large section separation | + +**Page Layout:** +| Token | Value | +|-------|-------| +| Page margins | 24px | +| Section gap | 24px | +| Card padding | 16px | +| Element gap | 8-12px | +| Background | `#FAFBFC` | +| Bottom safe area | 40px | ### Border Radii | Token | Value | Usage | |-------|-------|-------| -| sm | 8px | Small chips, badges, status pills, map placeholder | -| md | 12px | Cards, inputs, location cards, contact cards, search fields | -| lg | 14px | Buttons, CTA containers, shift cards (Find Shifts) | -| xl | 24px | Not commonly used | +| sm | 8px | Small chips, badges, status pills | +| md | 12px | Cards, inputs, list row containers, data rows | +| lg | 18px | Hero cards, gradient cards | +| xl | 24px | Large containers | | pill | 999px | Progress bar segments only | +### Icon Sizes + +Standard sizes: 16, 20, 24, 32dp + ## 2. Component Patterns ### Buttons -**Primary CTA:** -- Background: `#0A39DF`, radius: 14px, height: 52px -- Text: Manrope 16px/600, color: `#FFFFFF` -- Padding: 16px vertical, 16px horizontal +All buttons: radius 14px, padding 12px/24px, text Manrope 14px/600 -**Secondary/Outline Button:** -- Background: `#FFFFFF`, border: 1.5px `#D1D5DB`, radius: 14px, height: 52px -- Text: Manrope 16px/600, color: `#121826` +**Primary:** +- Background: `#0A39DF`, text: `#FFFFFF` -**Destructive Outline Button:** -- Background: `#FFFFFF`, border: 1.5px `#F04444`, radius: 14px -- Text: Manrope 14px/600, color: `#F04444` +**Secondary:** +- Background: `#F1F3F5`, border: 1.5px `#D1D5DB`, text: `#121826` + +**Destructive:** +- Background: `#F04444`, text: `#FFFFFF` + +**Disabled:** +- Background: `#F1F3F5`, no border, text: `#94A3B8` + +**Accept:** +- Background: `#10B981`, text: `#FFFFFF` + +**Dark:** +- Background: `#121826`, text: `#FFFFFF` + +**Full Width CTA:** +- Same as Primary but `width: 100%`, padding 14px/24px, text Manrope 15px/600 **Back Icon Button (Bottom CTA):** -- 52x52px square, border: 1.5px `#D1D5DB`, radius: 14px, background: `#FFFFFF` +- 52x52px square, border: 0.5px `#D1D5DB`, radius: 14px, background: `#FFFFFF` - Contains chevron-left SVG (20x20, viewBox 0 0 24 24, stroke `#121826`, strokeWidth 2) - Path: `M15 18L9 12L15 6` ### Chips +All chips: border 1.5px, text Manrope 14px/600, gap 8px for icon+text + **Default (Large) - for role/skill selection:** -- Selected: bg `#EFF6FF`, border 1.5px `#0A39DF`, radius 10px, padding 12px/16px - - Checkmark icon (14x14, stroke `#0A39DF`), text Manrope 14px/600 `#0A39DF` -- Unselected: bg `#FFFFFF`, border 1.5px `#6A7382`, radius 10px, padding 12px/16px +- Selected: bg `#EFF6FF`, border `#0A39DF`, radius 10px, padding 12px/16px + - Checkmark icon (14x14, stroke `#0A39DF`), text `#0A39DF` +- Unselected: bg `#FFFFFF`, border `#6A7382`, radius 10px, padding 12px/16px - Text Manrope 14px/500 `#6A7382` +**Warning Chips:** +- Selected: bg `#F9E5471A`, border `#E6A817`, radius 10px, padding 12px/16px + - Checkmark icon (stroke `#E6A817`), text `#E6A817` +- Unselected: bg `#FFFFFF`, border `#F0D78C`, radius 10px, padding 12px/16px + - Text `#E6A817` + +**Error Chips:** +- Selected: bg `#FEF2F2`, border `#F04444`, radius 10px, padding 12px/16px + - Checkmark icon (stroke `#F04444`), text `#F04444` +- Unselected: bg `#FFFFFF`, border `#FECACA`, radius 10px, padding 12px/16px + - Text `#F04444` + **Small - for tabs, filters:** - Selected: bg `#EFF6FF`, border 1.5px `#0A39DF`, radius 8px, padding 6px/12px - Checkmark icon (12x12), text Manrope 12px/600 `#0A39DF` @@ -119,8 +189,6 @@ This skill defines the design token system, component patterns, screen structure - Text Manrope 12px/500 `#6A7382` - Active (filled): bg `#0A39DF`, radius 8px, padding 6px/12px - Text Manrope 12px/600 `#FFFFFF` -- Dark (filters button): bg `#121826`, radius 8px, padding 6px/12px - - Text Manrope 12px/600 `#FFFFFF`, with leading icon **Status Badges:** - Radius: 8px, padding: 4px/8px @@ -131,39 +199,65 @@ This skill defines the design token system, component patterns, screen structure - Border: 1.5px `#E2E8F0`, radius: 12px, padding: 12px/14px - Background: `#FFFFFF` -- Placeholder: Manrope 14px/400, color `#6A7382` -- Filled: Manrope 14px/500, color `#121826` -- Label above: Manrope 14px/500, color `#121826` +- Placeholder: Manrope 14px/400, color `#94A3B8` +- Filled: Manrope 14px/400, color `#111827` +- Label above: Manrope 12px/400, spacing 0%: + - Default/filled: color `#94A3B8` + - Filled with value: color `#6A7382` + - Focused: color `#0A39DF` + - Error: color `#F04444` + - Disabled: color `#94A3B8` - Focused: border color `#0A39DF`, border-width 2px -- Error: border color `#F04444`, helper text `#F04444` +- Error: border color `#F04444`, border-width 2px, background `#FEF2F2` +- Error helper text: Manrope 12px/400, color `#F04444` -### Cards (Shift List Items) +### Border Width -- Background: `#FFFFFF`, border: 1px `#D1D5DB`, radius: 12-14px -- Padding: 16px -- Content: venue name (Manrope 15px/600 `#121826`), subtitle (Manrope 13px/400 `#6A7382`) -- Metadata row: icon (14px, `#6A7382`) + text (Manrope 13px/500 `#6A7382`) -- Pay rate: Inter Tight 18px/700 `#0A39DF` +- **Standard border width: `0.5px`** — All card borders, dividers, and outline buttons use `0.5px` unless explicitly stated otherwise. +- **Text inputs: `1.5px`** — To ensure visibility and distinction from card borders. +- **Chips: `1.5px`** — All chip variants (default, warning, error, small). +- **Secondary buttons: `1.5px`** — Outline/secondary button borders. -### Schedule/Pay Info Cards +### Cards -- Two-column layout with 12px gap -- Background: `#FFFFFF`, border: 1px `#D1D5DB`, radius: 12px, padding: 16px -- Label: Manrope 11px/500-700 uppercase `#6A7382` (letter-spacing 0.05em) -- Value: Inter Tight 18px/700 `#121826` (schedule) or `#121826` (pay) -- Sub-text: Manrope 13px/400 `#6A7382` +**Standard Card:** +- Background: `#FFFFFF`, border: 0.5px `#D1D5DB`, radius: 12px, padding: 16px +- Title: Inter Tight 16px/700 `#121826` +- Body: Manrope 14px/400 `#6A7382` +- Gap: 8px between title and body + +**Hero / Gradient Card:** +- Radius: 18px, padding: 20px, gap: 6px +- Background: gradient (mobileHero or adminHero) +- Label: Manrope 12px/400 `#FFFFFFB3` (white 70%) +- Value: Inter Tight 28px/700 `#FFFFFF` +- Sub-text: Manrope 12px/400 `#FFFFFF99` (white 60%) + +**List Rows (grouped):** +- Container: radius 12px, border 0.5px `#D1D5DB`, background `#FFFFFF`, overflow clip +- Row: padding ~16px, gap between text elements 2px +- Row title: Manrope 14px/600 `#121826` +- Row subtitle: Manrope 13px/400 `#6A7382` +- Row divider: 1px `#D1D5DB` (between rows, not on last) +- Chevron: `›` or SVG, `#6A7382` + +**Data Row:** +- Background: `#F1F3F5`, radius: 12px, padding: 12px +- Label: Manrope 11px/400 `#6A7382` +- Value: Inter Tight 20px/700 `#121826` +- Layout: flex row, equal width columns, gap 8px ### Contact/Info Rows -- Container: radius 12px, border 1px `#D1D5DB`, background `#FFFFFF`, overflow clip -- Row: padding 13px/16px, gap 10px, border-bottom 1px `#F1F3F5` (except last) +- Container: radius 12px, border 0.5px `#D1D5DB`, background `#FFFFFF`, overflow clip +- Row: padding 13px/16px, gap 10px, border-bottom 0.5px `#F1F3F5` (except last) - Icon: 16px, stroke `#6A7382` - Label: Manrope 13px/500 `#6A7382`, width 72px fixed - Value: Manrope 13px/500 `#121826` (or `#0A39DF` for phone/links) ### Section Headers -- Text: Manrope 11px/700, uppercase, letter-spacing 0.06em, color `#6A7382` +- Text: Manrope 11px/600, uppercase, letter-spacing 0.06em, color `#6A7382` - Gap to content below: 10px ## 3. Screen Structure diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 23463707..2f09c4a3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -2,16 +2,10 @@ ---- - ## 🔗 Related Issues -Closes # -Related to # - ---- ## 🎯 Type of Change @@ -26,8 +20,6 @@ Related to # - [ ] 🎨 **Style** (formatting, linting, or minor code style changes) - [ ] 🏗️ **Architecture** (significant structural changes) ---- - ## 📦 Affected Areas @@ -39,8 +31,6 @@ Related to # - [ ] 🚀 **CI/CD** (GitHub Actions, deployment configs) - [ ] 📚 **Documentation** (Docs, onboarding guides) ---- - ## ✅ Testing @@ -48,9 +38,6 @@ Related to # **Test Details:** - ---- - ## 🔄 Breaking Changes @@ -61,9 +48,6 @@ Related to # **Details:** - ---- - ## 🎯 Checklist @@ -79,15 +63,10 @@ Related to # - [ ] Sensitive data is not committed - [ ] Environment variables documented (if added) ---- - ## 📝 Additional Notes - ---- - ## 🔍 Review Checklist for Maintainers - [ ] Code quality and readability diff --git a/.github/workflows/backend-foundation.yml b/.github/workflows/backend-foundation.yml index 5fcdcc74..1d7523e9 100644 --- a/.github/workflows/backend-foundation.yml +++ b/.github/workflows/backend-foundation.yml @@ -26,6 +26,14 @@ jobs: make -n backend-smoke-core ENV=dev make -n backend-smoke-commands ENV=dev make -n backend-logs-core ENV=dev + make -n backend-bootstrap-v2-dev ENV=dev + make -n backend-deploy-core-v2 ENV=dev + make -n backend-deploy-commands-v2 ENV=dev + make -n backend-deploy-query-v2 ENV=dev + make -n backend-smoke-core-v2 ENV=dev + make -n backend-smoke-commands-v2 ENV=dev + make -n backend-smoke-query-v2 ENV=dev + make -n backend-logs-core-v2 ENV=dev backend-services-tests: runs-on: ubuntu-latest @@ -34,6 +42,7 @@ jobs: service: - backend/core-api - backend/command-api + - backend/query-api defaults: run: working-directory: ${{ matrix.service }} diff --git a/.gitignore b/.gitignore index eb271963..b47fdef0 100644 --- a/.gitignore +++ b/.gitignore @@ -182,6 +182,8 @@ internal/launchpad/prototypes-src/ # Temporary migration artifacts _legacy/ krow-workforce-export-latest/ +skills/ +skills-lock.json # Data Connect Generated SDKs (Explicit) apps/mobile/packages/data_connect/lib/src/dataconnect_generated/ @@ -194,4 +196,6 @@ apps/mobile/legacy/* AGENTS.md TASKS.md CLAUDE.md +.claude/agents/paper-designer.md +.claude/agent-memory/paper-designer \n# Android Signing (Secure)\n**.jks\n**key.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index b3b40911..e349edbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,3 +28,4 @@ | 2026-02-25 | 0.1.23 | Updated schema blueprint and reconciliation docs to add `business_memberships` and `vendor_memberships` as first-class data actors. | | 2026-02-25 | 0.1.24 | Removed stale `m4-discrepencies.md` document from M4 planning docs cleanup. | | 2026-02-25 | 0.1.25 | Added target schema model catalog with keys and domain relationship diagrams for slide/workshop use. | +| 2026-02-26 | 0.1.26 | Added isolated v2 backend foundation targets, scaffolded `backend/query-api`, and expanded backend CI dry-runs/tests for v2/query. | diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8b17176f..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,149 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -KROW Workforce is a workforce management platform monorepo containing Flutter mobile apps, a React web dashboard, and Firebase backend services. - -## Repository Structure - -``` -apps/mobile/ # Flutter monorepo (Melos workspace) - apps/staff/ # Staff mobile app - apps/client/ # Client (business) mobile app - packages/ - design_system/ # Shared UI tokens & components - core/ # Cross-cutting concerns (mixins, extensions) - core_localization/# i18n via Slang - domain/ # Pure Dart entities & failures - data_connect/ # Firebase Data Connect adapter (connectors) - features/staff/ # Staff feature packages - features/client/ # Client feature packages -apps/web/ # React/Vite web dashboard (TypeScript, Tailwind, Redux Toolkit) -backend/ - dataconnect/ # Firebase Data Connect GraphQL schemas - core-api/ # Core business logic service - cloud-functions/ # Serverless functions -``` - -## Common Commands - -All commands use the root `Makefile` (composed from `makefiles/*.mk`). Run `make help` for the full list. - -### Mobile (Flutter) -```bash -make mobile-install # Bootstrap Melos workspace + generate SDK -make mobile-staff-dev-android # Run staff app (add DEVICE=android) -make mobile-client-dev-android # Run client app -make mobile-analyze # Lint (flutter analyze) -make mobile-test # Run tests -make test-e2e # Maestro E2E tests (both apps) -``` - -Single-package operations via Melos: -```bash -cd apps/mobile -melos run gen:l10n # Generate localization (Slang) -melos run gen:build # Run build_runner -melos run analyze:all # Analyze all packages -melos run test:all # Test all packages -``` - -### Web (React/Vite) -```bash -make web-install # npm install -make web-dev # Start dev server -make web-build # Production build -make web-lint # ESLint -make web-test # Vitest -``` - -### Backend (Data Connect) -```bash -make dataconnect-generate-sdk [ENV=dev] # Generate SDK -make dataconnect-deploy [ENV=dev] # Deploy schemas -make dataconnect-sync-full [ENV=dev] # Deploy + migrate + generate -``` - -## Mobile Architecture - -**Clean Architecture** with strict inward dependency flow: - -``` -Presentation (Pages, BLoCs, Widgets) - → Application (Use Cases) - → Domain (Entities, Repository Interfaces, Failures) - ← Data (Repository Implementations, Connectors) -``` - -### Key Patterns - -- **State management:** Flutter BLoC/Cubit. Register BLoCs with `i.add()` (transient), never `i.addSingleton()`. Use `BlocProvider.value()` for shared BLoCs. -- **DI & Routing:** Flutter Modular. Safe navigation via `safeNavigate()`, `safePush()`, `popSafe()`. Never use `Navigator.push()` directly. -- **Error handling in BLoCs:** Use `BlocErrorHandler` mixin with `_safeEmit()` to prevent StateError on disposed BLoCs. -- **Backend access:** All Data Connect calls go through the `data_connect` package's Connectors. Use `_service.run(() => connector.().execute())` for automatic auth/token management. -- **Session management:** `SessionHandlerMixin` + `SessionListener` widget. Initialized in `main.dart` with role-based config. -- **Localization:** All user-facing strings via `context.strings.` from `core_localization`. Error messages via `ErrorTranslator`. -- **Design system:** Use tokens from `UiColors`, `UiTypography`, `UiConstants`. Never hardcode colors, fonts, or spacing. - -### Feature Package Structure - -New features go in `apps/mobile/packages/features///`: -``` -lib/src/ - domain/repositories/ # Abstract interface classes - data/repositories_impl/ # Implementations using data_connect - application/ # Use cases (business logic) - presentation/ - blocs/ # BLoCs/Cubits - pages/ # Pages (prefer StatelessWidget) - widgets/ # Reusable widgets -``` - -### Critical Rules - -- Features must not import other features directly -- Business logic belongs in Use Cases, never in BLoCs or widgets -- Firebase packages (`firebase_auth`, `firebase_data_connect`) belong only in `data_connect` -- Don't add 3rd-party packages without checking `packages/core` first -- Generated code directories are excluded from analysis: `**/dataconnect_generated/**`, `**/*.g.dart`, `**/*.freezed.dart` - -## Code Generation - -- **Slang** (i18n): Input `lib/src/l10n/*.i18n.json` → Output `strings.g.dart` -- **build_runner**: Various generated files (`.g.dart`, `.freezed.dart`) -- **Firebase Data Connect**: Auto-generated SDK in `packages/data_connect/lib/src/dataconnect_generated/` - -## Naming Conventions (Dart) - -| Type | Convention | Example | -|------|-----------|---------| -| Files | `snake_case` | `user_profile_page.dart` | -| Classes | `PascalCase` | `UserProfilePage` | -| Interfaces | suffix `Interface` | `AuthRepositoryInterface` | -| Implementations | suffix `Impl` | `AuthRepositoryImpl` | - -## Key Documentation - -- `docs/MOBILE/00-agent-development-rules.md` — Non-negotiable architecture rules -- `docs/MOBILE/01-architecture-principles.md` — Clean architecture details -- `docs/MOBILE/02-design-system-usage.md` — Design system token usage -- `docs/MOBILE/03-data-connect-connectors-pattern.md` — Backend integration pattern -- `docs/MOBILE/05-release-process.md` — Release quick reference -- `docs/RELEASE/mobile-releases.md` — Complete release guide - -## Skills & Sub-Agents - -#### Skills -- The project has 4 specialized skills in `.claude/skills/` that provide deep domain knowledge. Invoke them and other global skills that you have when working in their domains. - -#### Sub-Agents -- The project has 4 sub-agents in `.claude/sub-agents/` that can be invoked for specific tasks. Invoke them and other global sub-agents that you have when working in their domains. - - -## CI/CD - -- `.github/workflows/mobile-ci.yml` — Mobile build & test on PR -- `.github/workflows/product-release.yml` — Automated versioning, tags, APK builds -- `.github/workflows/web-quality.yml` — Web linting & tests diff --git a/Makefile b/Makefile index 98d82e42..d19df67e 100644 --- a/Makefile +++ b/Makefile @@ -89,6 +89,18 @@ help: @echo " make backend-smoke-commands [ENV=dev] Run health smoke test for command service (/health)" @echo " make backend-logs-core [ENV=dev] Tail/read logs for core service" @echo "" + @echo " ☁️ BACKEND FOUNDATION V2 (Isolated Parallel Stack)" + @echo " ────────────────────────────────────────────────────────────────────" + @echo " make backend-bootstrap-v2-dev [ENV=dev] Bootstrap isolated v2 resources + SQL instance" + @echo " make backend-deploy-core-v2 [ENV=dev] Build and deploy core API v2 service" + @echo " make backend-deploy-commands-v2 [ENV=dev] Build and deploy command API v2 service" + @echo " make backend-deploy-query-v2 [ENV=dev] Build and deploy query API v2 scaffold" + @echo " make backend-v2-migrate-idempotency Create/upgrade command idempotency table for v2 DB" + @echo " make backend-smoke-core-v2 [ENV=dev] Run health smoke test for core API v2 (/health)" + @echo " make backend-smoke-commands-v2 [ENV=dev] Run health smoke test for command API v2 (/health)" + @echo " make backend-smoke-query-v2 [ENV=dev] Run health smoke test for query API v2 (/health)" + @echo " make backend-logs-core-v2 [ENV=dev] Tail/read logs for core API v2" + @echo "" @echo " 🛠️ DEVELOPMENT TOOLS" @echo " ────────────────────────────────────────────────────────────────────" @echo " make install-melos Install Melos globally (for mobile dev)" diff --git a/apps/mobile/apps/client/android/app/src/dev/res/mipmap-hdpi/launcher_icon.png b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 00000000..d49bbb8c Binary files /dev/null and b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-hdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/client/android/app/src/dev/res/mipmap-mdpi/launcher_icon.png b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 00000000..9007c17a Binary files /dev/null and b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-mdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/client/android/app/src/dev/res/mipmap-xhdpi/launcher_icon.png b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 00000000..bdbc72ac Binary files /dev/null and b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/client/android/app/src/dev/res/mipmap-xxhdpi/launcher_icon.png b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 00000000..6779c5b9 Binary files /dev/null and b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/client/android/app/src/dev/res/mipmap-xxxhdpi/launcher_icon.png b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 00000000..ad4ebe43 Binary files /dev/null and b/apps/mobile/apps/client/android/app/src/dev/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/client/android/app/src/dev/res/values/ic_launcher.xml b/apps/mobile/apps/client/android/app/src/dev/res/values/ic_launcher.xml new file mode 100644 index 00000000..72e01a91 --- /dev/null +++ b/apps/mobile/apps/client/android/app/src/dev/res/values/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/apps/client/android/app/src/stage/res/mipmap-hdpi/launcher_icon.png b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 00000000..d4c42c19 Binary files /dev/null and b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-hdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/client/android/app/src/stage/res/mipmap-mdpi/launcher_icon.png b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 00000000..c790375a Binary files /dev/null and b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-mdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/client/android/app/src/stage/res/mipmap-xhdpi/launcher_icon.png b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 00000000..7141d7a0 Binary files /dev/null and b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/client/android/app/src/stage/res/mipmap-xxhdpi/launcher_icon.png b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 00000000..6e548385 Binary files /dev/null and b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/client/android/app/src/stage/res/mipmap-xxxhdpi/launcher_icon.png b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 00000000..bd654f8e Binary files /dev/null and b/apps/mobile/apps/client/android/app/src/stage/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/client/android/app/src/stage/res/values/ic_launcher.xml b/apps/mobile/apps/client/android/app/src/stage/res/values/ic_launcher.xml new file mode 100644 index 00000000..72e01a91 --- /dev/null +++ b/apps/mobile/apps/client/android/app/src/stage/res/values/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/apps/client/assets/logo-dev.png b/apps/mobile/apps/client/assets/logo-dev.png new file mode 100644 index 00000000..d7e888f8 Binary files /dev/null and b/apps/mobile/apps/client/assets/logo-dev.png differ diff --git a/apps/mobile/apps/client/assets/logo-stage.png b/apps/mobile/apps/client/assets/logo-stage.png new file mode 100644 index 00000000..13650e6d Binary files /dev/null and b/apps/mobile/apps/client/assets/logo-stage.png differ diff --git a/apps/mobile/apps/client/ios/Flutter/Dev.xcconfig b/apps/mobile/apps/client/ios/Flutter/Dev.xcconfig new file mode 100644 index 00000000..1cf7844f --- /dev/null +++ b/apps/mobile/apps/client/ios/Flutter/Dev.xcconfig @@ -0,0 +1,2 @@ +// Build configuration for dev flavor - use AppIcon-dev +ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-dev diff --git a/apps/mobile/apps/client/ios/Flutter/Stage.xcconfig b/apps/mobile/apps/client/ios/Flutter/Stage.xcconfig new file mode 100644 index 00000000..1c32ddd9 --- /dev/null +++ b/apps/mobile/apps/client/ios/Flutter/Stage.xcconfig @@ -0,0 +1,2 @@ +// Build configuration for stage flavor - use AppIcon-stage +ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-stage diff --git a/apps/mobile/apps/client/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/apps/client/ios/Runner.xcodeproj/project.pbxproj index b4c57563..d03d2b39 100644 --- a/apps/mobile/apps/client/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/apps/client/ios/Runner.xcodeproj/project.pbxproj @@ -256,7 +256,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; BC26E38F2F5F614000517BDF /* ShellScript */ = { isa = PBXShellScriptBuildPhase; @@ -383,7 +383,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { APP_NAME = "KROW With Us Business [STAGE]"; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -563,7 +563,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { APP_NAME = "KROW With Us Business [DEV] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -665,7 +665,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { APP_NAME = "KROW With Us Business [DEV] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -762,7 +762,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { APP_NAME = "KROW With Us Business [STAGE] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -951,7 +951,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { APP_NAME = "KROW With Us Business [DEV] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -1040,7 +1040,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { APP_NAME = "KROW With Us Business [STAGE]"; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -1220,7 +1220,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { APP_NAME = "KROW With Us Business [DEV] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -1311,7 +1311,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { APP_NAME = "KROW With Us Business [STAGE] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json new file mode 100644 index 00000000..d0d98aa1 --- /dev/null +++ b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-1024x1024@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..33cfd814 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..5f527137 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..da57bdc1 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@3x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..8ad994f0 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..532aea6f Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..a1929f2b Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@3x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..1472048e Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..da57bdc1 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..d9ae6d33 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@3x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..141c5928 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 00000000..425fef8e Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 00000000..256ba404 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 00000000..b54d7090 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 00000000..086f6198 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..141c5928 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@3x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..3b9d45e2 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 00000000..7febd04d Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 00000000..bb430049 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..cf010a0b Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..31875682 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-83.5x83.5@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..eb5f019a Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Contents.json b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Contents.json new file mode 100644 index 00000000..d0d98aa1 --- /dev/null +++ b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-1024x1024@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..f2c78577 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..4a0e6692 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..8437d8c0 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@3x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..36b76f8f Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..ae36ba13 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..de7f6dd9 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@3x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..db506359 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..8437d8c0 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..64a6e05d Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@3x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..d49e47f9 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 00000000..088c0273 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 00000000..9f3a128b Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 00000000..12aa8b4d Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 00000000..5721c210 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..d49e47f9 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@3x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..c5178177 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 00000000..c5c3e185 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 00000000..6fb9d895 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@1x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..97217ce1 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..99eeb561 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-83.5x83.5@2x.png b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..fbbb7d57 Binary files /dev/null and b/apps/mobile/apps/client/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-hdpi/launcher_icon.png b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 00000000..04dc5070 Binary files /dev/null and b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-hdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-mdpi/launcher_icon.png b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 00000000..27d2bc52 Binary files /dev/null and b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-mdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-xhdpi/launcher_icon.png b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 00000000..42667c8d Binary files /dev/null and b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-xxhdpi/launcher_icon.png b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 00000000..fe7a9a50 Binary files /dev/null and b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-xxxhdpi/launcher_icon.png b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 00000000..05c99031 Binary files /dev/null and b/apps/mobile/apps/staff/android/app/src/dev/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/staff/android/app/src/dev/res/values/ic_launcher.xml b/apps/mobile/apps/staff/android/app/src/dev/res/values/ic_launcher.xml new file mode 100644 index 00000000..72e01a91 --- /dev/null +++ b/apps/mobile/apps/staff/android/app/src/dev/res/values/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-hdpi/launcher_icon.png b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-hdpi/launcher_icon.png new file mode 100644 index 00000000..c9f767bd Binary files /dev/null and b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-hdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-mdpi/launcher_icon.png b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-mdpi/launcher_icon.png new file mode 100644 index 00000000..50de2d62 Binary files /dev/null and b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-mdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-xhdpi/launcher_icon.png b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-xhdpi/launcher_icon.png new file mode 100644 index 00000000..e4166943 Binary files /dev/null and b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-xhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-xxhdpi/launcher_icon.png b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-xxhdpi/launcher_icon.png new file mode 100644 index 00000000..dbe980a4 Binary files /dev/null and b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-xxhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-xxxhdpi/launcher_icon.png b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-xxxhdpi/launcher_icon.png new file mode 100644 index 00000000..b1975011 Binary files /dev/null and b/apps/mobile/apps/staff/android/app/src/stage/res/mipmap-xxxhdpi/launcher_icon.png differ diff --git a/apps/mobile/apps/staff/android/app/src/stage/res/values/ic_launcher.xml b/apps/mobile/apps/staff/android/app/src/stage/res/values/ic_launcher.xml new file mode 100644 index 00000000..72e01a91 --- /dev/null +++ b/apps/mobile/apps/staff/android/app/src/stage/res/values/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/mobile/apps/staff/assets/logo-dev.png b/apps/mobile/apps/staff/assets/logo-dev.png new file mode 100644 index 00000000..49d71834 Binary files /dev/null and b/apps/mobile/apps/staff/assets/logo-dev.png differ diff --git a/apps/mobile/apps/staff/assets/logo-stage.png b/apps/mobile/apps/staff/assets/logo-stage.png new file mode 100644 index 00000000..7b11e59a Binary files /dev/null and b/apps/mobile/apps/staff/assets/logo-stage.png differ diff --git a/apps/mobile/apps/staff/ios/Flutter/Dev.xcconfig b/apps/mobile/apps/staff/ios/Flutter/Dev.xcconfig new file mode 100644 index 00000000..1cf7844f --- /dev/null +++ b/apps/mobile/apps/staff/ios/Flutter/Dev.xcconfig @@ -0,0 +1,2 @@ +// Build configuration for dev flavor - use AppIcon-dev +ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-dev diff --git a/apps/mobile/apps/staff/ios/Flutter/Stage.xcconfig b/apps/mobile/apps/staff/ios/Flutter/Stage.xcconfig new file mode 100644 index 00000000..1c32ddd9 --- /dev/null +++ b/apps/mobile/apps/staff/ios/Flutter/Stage.xcconfig @@ -0,0 +1,2 @@ +// Build configuration for stage flavor - use AppIcon-stage +ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-stage diff --git a/apps/mobile/apps/staff/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/apps/staff/ios/Runner.xcodeproj/project.pbxproj index b3030a13..6aa920e9 100644 --- a/apps/mobile/apps/staff/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/apps/staff/ios/Runner.xcodeproj/project.pbxproj @@ -384,7 +384,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { APP_NAME = "KROW With Us [STAGE] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -564,7 +564,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { APP_NAME = "KROW With Us [DEV] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -666,7 +666,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { APP_NAME = "KROW With Us [DEV] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -763,7 +763,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { APP_NAME = "KROW With Us [STAGE] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -954,7 +954,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { APP_NAME = "KROW With Us [DEV] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -1045,7 +1045,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { APP_NAME = "KROW With Us [STAGE] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -1225,7 +1225,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { APP_NAME = "KROW With Us [DEV] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-dev"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; @@ -1314,7 +1314,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { APP_NAME = "KROW With Us [STAGE] "; - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = "AppIcon-stage"; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json new file mode 100644 index 00000000..d0d98aa1 --- /dev/null +++ b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-1024x1024@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..245225a9 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..9498c539 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..2d2a3d6b Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@3x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..2fed90a9 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..6a61a35b Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..ade8731f Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@3x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..f6ecf0f8 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..2d2a3d6b Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..e3ccb67b Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@3x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..fc8cdae3 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 00000000..1727d894 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 00000000..f9958e01 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-50x50@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 00000000..b9c500f6 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 00000000..cb3ac623 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-57x57@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..fc8cdae3 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@3x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..108ae1a0 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 00000000..c96339b6 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 00000000..903a6ceb Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-72x72@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..65981b8b Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..19596c28 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-83.5x83.5@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..b2979320 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-dev.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Contents.json b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Contents.json new file mode 100644 index 00000000..d0d98aa1 --- /dev/null +++ b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Contents.json @@ -0,0 +1 @@ +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-1024x1024@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 00000000..dbf0c826 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 00000000..9f70b76d Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 00000000..e86fc3c7 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@3x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 00000000..673ad708 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-20x20@3x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 00000000..e0a60197 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 00000000..6482ce67 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@3x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 00000000..c110a199 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-29x29@3x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 00000000..e86fc3c7 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 00000000..8c5d9752 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@3x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 00000000..1a0ee9b3 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-40x40@3x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@1x.png new file mode 100644 index 00000000..dfdc92d0 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@2x.png new file mode 100644 index 00000000..7fadd6f0 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-50x50@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@1x.png new file mode 100644 index 00000000..7007f609 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@2x.png new file mode 100644 index 00000000..d031a149 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-57x57@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 00000000..1a0ee9b3 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@3x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 00000000..1313d0e6 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-60x60@3x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@1x.png new file mode 100644 index 00000000..4cfabad1 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@2x.png new file mode 100644 index 00000000..49f5de35 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-72x72@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@1x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 00000000..d03a9b6c Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@1x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 00000000..720ce010 Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-76x76@2x.png differ diff --git a/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-83.5x83.5@2x.png b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 00000000..57f9499d Binary files /dev/null and b/apps/mobile/apps/staff/ios/Runner/Assets.xcassets/AppIcon-stage.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/backend/command-api/Dockerfile b/backend/command-api/Dockerfile index 55a6a26b..93e1dc48 100644 --- a/backend/command-api/Dockerfile +++ b/backend/command-api/Dockerfile @@ -6,6 +6,7 @@ COPY package*.json ./ RUN npm ci --omit=dev COPY src ./src +COPY scripts ./scripts ENV PORT=8080 EXPOSE 8080 diff --git a/backend/command-api/package-lock.json b/backend/command-api/package-lock.json index 2b8f2d6c..f12cea57 100644 --- a/backend/command-api/package-lock.json +++ b/backend/command-api/package-lock.json @@ -8,6 +8,7 @@ "name": "@krow/command-api", "version": "0.1.0", "dependencies": { + "@google-cloud/storage": "^7.19.0", "express": "^4.21.2", "firebase-admin": "^13.0.2", "pg": "^8.16.3", @@ -151,7 +152,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", "license": "Apache-2.0", - "optional": true, "dependencies": { "arrify": "^2.0.0", "extend": "^3.0.2" @@ -165,7 +165,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14.0.0" } @@ -175,7 +174,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=14" } @@ -185,7 +183,6 @@ "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@google-cloud/paginator": "^5.0.0", "@google-cloud/projectify": "^4.0.0", @@ -212,7 +209,6 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -398,7 +394,6 @@ "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", "license": "MIT", - "optional": true, "engines": { "node": ">= 10" } @@ -407,8 +402,7 @@ "version": "0.12.5", "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", @@ -447,7 +441,6 @@ "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", "license": "MIT", - "optional": true, "dependencies": { "@types/caseless": "*", "@types/node": "*", @@ -459,15 +452,13 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", "license": "MIT", - "optional": true, "dependencies": { "event-target-shim": "^5.0.0" }, @@ -534,7 +525,6 @@ "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -551,7 +541,6 @@ "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", "license": "MIT", - "optional": true, "dependencies": { "retry": "0.13.1" } @@ -560,7 +549,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "devOptional": true, "license": "MIT" }, "node_modules/atomic-sleep": { @@ -708,7 +696,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "devOptional": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -783,7 +770,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "devOptional": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -838,7 +824,6 @@ "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", "license": "MIT", - "optional": true, "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", @@ -882,7 +867,6 @@ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", "license": "MIT", - "optional": true, "dependencies": { "once": "^1.4.0" } @@ -921,7 +905,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -963,7 +946,6 @@ "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=6" } @@ -1053,7 +1035,6 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "strnum": "^2.1.2" }, @@ -1122,7 +1103,6 @@ "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", "license": "MIT", - "optional": true, "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -1381,7 +1361,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "devOptional": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -1419,8 +1398,7 @@ "url": "https://patreon.com/mdevils" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/http-errors": { "version": "2.0.1", @@ -1453,7 +1431,6 @@ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", "license": "MIT", - "optional": true, "dependencies": { "@tootallnate/once": "2", "agent-base": "6", @@ -1468,7 +1445,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -1481,7 +1457,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -1498,8 +1473,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/https-proxy-agent": { "version": "7.0.6", @@ -1822,7 +1796,6 @@ "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", "license": "MIT", - "optional": true, "bin": { "mime": "cli.js" }, @@ -1942,7 +1915,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "devOptional": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -1953,7 +1925,6 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "license": "MIT", - "optional": true, "dependencies": { "yocto-queue": "^0.1.0" }, @@ -2273,7 +2244,6 @@ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "license": "MIT", - "optional": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -2307,7 +2277,6 @@ "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", "license": "MIT", - "optional": true, "engines": { "node": ">= 4" } @@ -2317,7 +2286,6 @@ "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", "license": "MIT", - "optional": true, "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", @@ -2541,7 +2509,6 @@ "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", "license": "MIT", - "optional": true, "dependencies": { "stubs": "^3.0.0" } @@ -2550,15 +2517,13 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "license": "MIT", - "optional": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -2601,15 +2566,13 @@ "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/stubs": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/superagent": { "version": "10.3.0", @@ -2717,7 +2680,6 @@ "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", "license": "Apache-2.0", - "optional": true, "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", @@ -2734,7 +2696,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "optional": true, "dependencies": { "debug": "4" }, @@ -2747,7 +2708,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "optional": true, "dependencies": { "ms": "^2.1.3" }, @@ -2765,7 +2725,6 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", - "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -2778,8 +2737,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/teeny-request/node_modules/uuid": { "version": "9.0.1", @@ -2790,7 +2748,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "optional": true, "bin": { "uuid": "dist/bin/uuid" } @@ -2857,8 +2814,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT", - "optional": true + "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -2952,7 +2908,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "devOptional": true, "license": "ISC" }, "node_modules/xtend": { @@ -3014,7 +2969,6 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, diff --git a/backend/command-api/package.json b/backend/command-api/package.json index c47230cf..bf873541 100644 --- a/backend/command-api/package.json +++ b/backend/command-api/package.json @@ -9,9 +9,14 @@ "scripts": { "start": "node src/server.js", "test": "node --test", - "migrate:idempotency": "node scripts/migrate-idempotency.mjs" + "dispatch:notifications": "node scripts/dispatch-notifications.mjs", + "migrate:idempotency": "node scripts/migrate-idempotency.mjs", + "migrate:v2-schema": "node scripts/migrate-v2-schema.mjs", + "seed:v2-demo": "node scripts/seed-v2-demo-data.mjs", + "smoke:v2-live": "node scripts/live-smoke-v2.mjs" }, "dependencies": { + "@google-cloud/storage": "^7.19.0", "express": "^4.21.2", "firebase-admin": "^13.0.2", "pg": "^8.16.3", diff --git a/backend/command-api/scripts/dispatch-notifications.mjs b/backend/command-api/scripts/dispatch-notifications.mjs new file mode 100644 index 00000000..2ab3ff4f --- /dev/null +++ b/backend/command-api/scripts/dispatch-notifications.mjs @@ -0,0 +1,14 @@ +import { dispatchPendingNotifications } from '../src/services/notification-dispatcher.js'; +import { closePool } from '../src/services/db.js'; + +try { + const summary = await dispatchPendingNotifications(); + // eslint-disable-next-line no-console + console.log(JSON.stringify({ ok: true, summary }, null, 2)); +} catch (error) { + // eslint-disable-next-line no-console + console.error(JSON.stringify({ ok: false, error: error?.message || String(error) }, null, 2)); + process.exitCode = 1; +} finally { + await closePool(); +} diff --git a/backend/command-api/scripts/live-smoke-v2.mjs b/backend/command-api/scripts/live-smoke-v2.mjs new file mode 100644 index 00000000..ae08ba29 --- /dev/null +++ b/backend/command-api/scripts/live-smoke-v2.mjs @@ -0,0 +1,348 @@ +import assert from 'node:assert/strict'; +import { V2DemoFixture as fixture } from './v2-demo-fixture.mjs'; + +const firebaseApiKey = process.env.FIREBASE_API_KEY || 'AIzaSyBqRtZPMGU-Sz5x5UnRrunKu5NSWYyPRn8'; +const demoEmail = process.env.V2_SMOKE_EMAIL || fixture.users.businessOwner.email; +const demoPassword = process.env.V2_SMOKE_PASSWORD || 'Demo2026!'; +const commandBaseUrl = process.env.COMMAND_API_BASE_URL || 'https://krow-command-api-v2-e3g6witsvq-uc.a.run.app'; +const queryBaseUrl = process.env.QUERY_API_BASE_URL || 'https://krow-query-api-v2-e3g6witsvq-uc.a.run.app'; + +async function signInWithPassword() { + const response = await fetch( + `https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${firebaseApiKey}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email: demoEmail, + password: demoPassword, + returnSecureToken: true, + }), + } + ); + + const payload = await response.json(); + if (!response.ok) { + throw new Error(`Firebase sign-in failed: ${JSON.stringify(payload)}`); + } + + return { + idToken: payload.idToken, + localId: payload.localId, + }; +} + +async function apiCall(baseUrl, path, { + method = 'GET', + token, + idempotencyKey, + body, + expectedStatus = 200, +} = {}) { + const headers = {}; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + if (idempotencyKey) { + headers['Idempotency-Key'] = idempotencyKey; + } + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(`${baseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const text = await response.text(); + const payload = text ? JSON.parse(text) : {}; + + if (response.status !== expectedStatus) { + throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${text}`); + } + + return payload; +} + +function uniqueKey(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function logStep(step, payload) { + // eslint-disable-next-line no-console + console.log(`[live-smoke-v2] ${step}: ${JSON.stringify(payload)}`); +} + +async function main() { + const auth = await signInWithPassword(); + assert.equal(auth.localId, fixture.users.businessOwner.id); + logStep('auth.ok', { uid: auth.localId, email: demoEmail }); + + const listOrders = await apiCall( + queryBaseUrl, + `/query/tenants/${fixture.tenant.id}/orders`, + { token: auth.idToken } + ); + assert.ok(Array.isArray(listOrders.items)); + assert.ok(listOrders.items.some((item) => item.id === fixture.orders.open.id)); + logStep('orders.list.ok', { count: listOrders.items.length }); + + const openOrderDetail = await apiCall( + queryBaseUrl, + `/query/tenants/${fixture.tenant.id}/orders/${fixture.orders.open.id}`, + { token: auth.idToken } + ); + assert.equal(openOrderDetail.id, fixture.orders.open.id); + assert.equal(openOrderDetail.shifts[0].id, fixture.shifts.open.id); + logStep('orders.detail.ok', { orderId: openOrderDetail.id, shiftCount: openOrderDetail.shifts.length }); + + const favoriteResult = await apiCall( + commandBaseUrl, + `/commands/businesses/${fixture.business.id}/favorite-staff`, + { + method: 'POST', + token: auth.idToken, + idempotencyKey: uniqueKey('favorite'), + body: { + tenantId: fixture.tenant.id, + staffId: fixture.staff.ana.id, + }, + } + ); + assert.equal(favoriteResult.staffId, fixture.staff.ana.id); + logStep('favorites.add.ok', favoriteResult); + + const favoriteList = await apiCall( + queryBaseUrl, + `/query/tenants/${fixture.tenant.id}/businesses/${fixture.business.id}/favorite-staff`, + { token: auth.idToken } + ); + assert.ok(favoriteList.items.some((item) => item.staffId === fixture.staff.ana.id)); + logStep('favorites.list.ok', { count: favoriteList.items.length }); + + const reviewResult = await apiCall( + commandBaseUrl, + `/commands/assignments/${fixture.assignments.completedAna.id}/reviews`, + { + method: 'POST', + token: auth.idToken, + idempotencyKey: uniqueKey('review'), + body: { + tenantId: fixture.tenant.id, + businessId: fixture.business.id, + staffId: fixture.staff.ana.id, + rating: 5, + reviewText: 'Live smoke review', + tags: ['smoke', 'reliable'], + }, + } + ); + assert.equal(reviewResult.staffId, fixture.staff.ana.id); + logStep('reviews.create.ok', reviewResult); + + const reviewSummary = await apiCall( + queryBaseUrl, + `/query/tenants/${fixture.tenant.id}/staff/${fixture.staff.ana.id}/review-summary`, + { token: auth.idToken } + ); + assert.equal(reviewSummary.staffId, fixture.staff.ana.id); + assert.ok(reviewSummary.ratingCount >= 1); + logStep('reviews.summary.ok', { ratingCount: reviewSummary.ratingCount, averageRating: reviewSummary.averageRating }); + + const assigned = await apiCall( + commandBaseUrl, + `/commands/shifts/${fixture.shifts.open.id}/assign-staff`, + { + method: 'POST', + token: auth.idToken, + idempotencyKey: uniqueKey('assign'), + body: { + tenantId: fixture.tenant.id, + shiftRoleId: fixture.shiftRoles.openBarista.id, + workforceId: fixture.workforce.ana.id, + applicationId: fixture.applications.openAna.id, + }, + } + ); + assert.equal(assigned.shiftId, fixture.shifts.open.id); + logStep('assign.ok', assigned); + + const accepted = await apiCall( + commandBaseUrl, + `/commands/shifts/${fixture.shifts.open.id}/accept`, + { + method: 'POST', + token: auth.idToken, + idempotencyKey: uniqueKey('accept'), + body: { + shiftRoleId: fixture.shiftRoles.openBarista.id, + workforceId: fixture.workforce.ana.id, + }, + } + ); + assert.ok(['ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED'].includes(accepted.status)); + const liveAssignmentId = accepted.assignmentId || assigned.assignmentId; + logStep('accept.ok', accepted); + + const clockIn = await apiCall( + commandBaseUrl, + '/commands/attendance/clock-in', + { + method: 'POST', + token: auth.idToken, + idempotencyKey: uniqueKey('clockin'), + body: { + assignmentId: liveAssignmentId, + sourceType: 'NFC', + sourceReference: 'smoke', + nfcTagUid: fixture.clockPoint.nfcTagUid, + deviceId: 'smoke-device', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + accuracyMeters: 5, + }, + } + ); + assert.equal(clockIn.assignmentId, liveAssignmentId); + logStep('attendance.clockin.ok', clockIn); + + const clockOut = await apiCall( + commandBaseUrl, + '/commands/attendance/clock-out', + { + method: 'POST', + token: auth.idToken, + idempotencyKey: uniqueKey('clockout'), + body: { + assignmentId: liveAssignmentId, + sourceType: 'NFC', + sourceReference: 'smoke', + nfcTagUid: fixture.clockPoint.nfcTagUid, + deviceId: 'smoke-device', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + accuracyMeters: 5, + }, + } + ); + assert.equal(clockOut.assignmentId, liveAssignmentId); + logStep('attendance.clockout.ok', clockOut); + + const attendance = await apiCall( + queryBaseUrl, + `/query/tenants/${fixture.tenant.id}/assignments/${liveAssignmentId}/attendance`, + { token: auth.idToken } + ); + assert.ok(Array.isArray(attendance.events)); + assert.ok(attendance.events.length >= 2); + logStep('attendance.query.ok', { eventCount: attendance.events.length, sessionStatus: attendance.sessionStatus }); + + const orderNumber = `ORD-V2-SMOKE-${Date.now()}`; + const createdOrder = await apiCall( + commandBaseUrl, + '/commands/orders/create', + { + method: 'POST', + token: auth.idToken, + idempotencyKey: uniqueKey('order-create'), + body: { + tenantId: fixture.tenant.id, + businessId: fixture.business.id, + vendorId: fixture.vendor.id, + orderNumber, + title: 'Smoke created order', + serviceType: 'EVENT', + shifts: [ + { + shiftCode: `SHIFT-${Date.now()}`, + title: 'Smoke shift', + startsAt: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), + endsAt: new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(), + requiredWorkers: 1, + clockPointId: fixture.clockPoint.id, + roles: [ + { + roleCode: fixture.roles.barista.code, + roleName: fixture.roles.barista.name, + workersNeeded: 1, + payRateCents: 2200, + billRateCents: 3500, + }, + ], + }, + ], + }, + } + ); + assert.equal(createdOrder.orderNumber, orderNumber); + logStep('orders.create.ok', createdOrder); + + const updatedOrder = await apiCall( + commandBaseUrl, + `/commands/orders/${createdOrder.orderId}/update`, + { + method: 'POST', + token: auth.idToken, + idempotencyKey: uniqueKey('order-update'), + body: { + tenantId: fixture.tenant.id, + title: 'Smoke updated order', + notes: 'updated during live smoke', + }, + } + ); + assert.equal(updatedOrder.orderId, createdOrder.orderId); + logStep('orders.update.ok', updatedOrder); + + const changedShift = await apiCall( + commandBaseUrl, + `/commands/shifts/${createdOrder.shiftIds[0]}/change-status`, + { + method: 'POST', + token: auth.idToken, + idempotencyKey: uniqueKey('shift-status'), + body: { + tenantId: fixture.tenant.id, + status: 'PENDING_CONFIRMATION', + reason: 'live smoke transition', + }, + } + ); + assert.equal(changedShift.status, 'PENDING_CONFIRMATION'); + logStep('shift.status.ok', changedShift); + + const cancelledOrder = await apiCall( + commandBaseUrl, + `/commands/orders/${createdOrder.orderId}/cancel`, + { + method: 'POST', + token: auth.idToken, + idempotencyKey: uniqueKey('order-cancel'), + body: { + tenantId: fixture.tenant.id, + reason: 'live smoke cleanup', + }, + } + ); + assert.equal(cancelledOrder.status, 'CANCELLED'); + logStep('orders.cancel.ok', cancelledOrder); + + const cancelledOrderDetail = await apiCall( + queryBaseUrl, + `/query/tenants/${fixture.tenant.id}/orders/${createdOrder.orderId}`, + { token: auth.idToken } + ); + assert.equal(cancelledOrderDetail.status, 'CANCELLED'); + logStep('orders.cancel.verify.ok', { orderId: cancelledOrderDetail.id, status: cancelledOrderDetail.status }); + + // eslint-disable-next-line no-console + console.log('LIVE_SMOKE_V2_OK'); +} + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exit(1); +}); diff --git a/backend/command-api/scripts/migrate-idempotency.mjs b/backend/command-api/scripts/migrate-idempotency.mjs index 42970b84..f7f275f6 100644 --- a/backend/command-api/scripts/migrate-idempotency.mjs +++ b/backend/command-api/scripts/migrate-idempotency.mjs @@ -3,11 +3,11 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { Pool } from 'pg'; -const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL; +const databaseUrl = process.env.IDEMPOTENCY_DATABASE_URL || process.env.DATABASE_URL; if (!databaseUrl) { // eslint-disable-next-line no-console - console.error('IDEMPOTENCY_DATABASE_URL is required'); + console.error('IDEMPOTENCY_DATABASE_URL or DATABASE_URL is required'); process.exit(1); } diff --git a/backend/command-api/scripts/migrate-v2-schema.mjs b/backend/command-api/scripts/migrate-v2-schema.mjs new file mode 100644 index 00000000..eb7dc0f4 --- /dev/null +++ b/backend/command-api/scripts/migrate-v2-schema.mjs @@ -0,0 +1,69 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Pool } from 'pg'; + +const databaseUrl = process.env.DATABASE_URL; + +if (!databaseUrl) { + // eslint-disable-next-line no-console + console.error('DATABASE_URL is required'); + process.exit(1); +} + +const scriptDir = resolve(fileURLToPath(new URL('.', import.meta.url))); +const migrationsDir = resolve(scriptDir, '../sql/v2'); + +const migrationFiles = readdirSync(migrationsDir) + .filter((file) => file.endsWith('.sql')) + .sort(); + +const pool = new Pool({ + connectionString: databaseUrl, + max: Number.parseInt(process.env.DB_POOL_MAX || '5', 10), +}); + +async function ensureMigrationTable(client) { + await client.query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version TEXT PRIMARY KEY, + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); +} + +try { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + await ensureMigrationTable(client); + + for (const file of migrationFiles) { + const alreadyApplied = await client.query( + 'SELECT 1 FROM schema_migrations WHERE version = $1', + [file] + ); + if (alreadyApplied.rowCount > 0) { + continue; + } + + const sql = readFileSync(resolve(migrationsDir, file), 'utf8'); + await client.query(sql); + await client.query( + 'INSERT INTO schema_migrations (version) VALUES ($1)', + [file] + ); + // eslint-disable-next-line no-console + console.log(`Applied migration ${file}`); + } + + await client.query('COMMIT'); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} finally { + await pool.end(); +} diff --git a/backend/command-api/scripts/seed-v2-demo-data.mjs b/backend/command-api/scripts/seed-v2-demo-data.mjs new file mode 100644 index 00000000..a2cd2dba --- /dev/null +++ b/backend/command-api/scripts/seed-v2-demo-data.mjs @@ -0,0 +1,965 @@ +import { Pool } from 'pg'; +import { resolveDatabasePoolConfig } from '../src/services/db.js'; +import { V2DemoFixture as fixture } from './v2-demo-fixture.mjs'; + +const poolConfig = resolveDatabasePoolConfig(); + +if (!poolConfig) { + // eslint-disable-next-line no-console + console.error('Database connection settings are required'); + process.exit(1); +} + +const pool = new Pool(poolConfig); + +function hoursFromNow(hours) { + return new Date(Date.now() + (hours * 60 * 60 * 1000)).toISOString(); +} + +async function upsertUser(client, user) { + await client.query( + ` + INSERT INTO users (id, email, display_name, status, metadata) + VALUES ($1, $2, $3, 'ACTIVE', '{}'::jsonb) + ON CONFLICT (id) DO UPDATE + SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name, + status = 'ACTIVE', + updated_at = NOW() + `, + [user.id, user.email || null, user.displayName || null] + ); +} + +async function main() { + const client = await pool.connect(); + try { + await client.query('BEGIN'); + + await client.query('DELETE FROM tenants WHERE id = $1', [fixture.tenant.id]); + + const openStartsAt = hoursFromNow(4); + const openEndsAt = hoursFromNow(12); + const completedStartsAt = hoursFromNow(-28); + const completedEndsAt = hoursFromNow(-20); + const checkedInAt = hoursFromNow(-27.5); + const checkedOutAt = hoursFromNow(-20.25); + const assignedStartsAt = hoursFromNow(0.1); + const assignedEndsAt = hoursFromNow(8.1); + const availableStartsAt = hoursFromNow(30); + const availableEndsAt = hoursFromNow(38); + const cancelledStartsAt = hoursFromNow(20); + const cancelledEndsAt = hoursFromNow(28); + const noShowStartsAt = hoursFromNow(-18); + const noShowEndsAt = hoursFromNow(-10); + const invoiceDueAt = hoursFromNow(72); + + await upsertUser(client, fixture.users.businessOwner); + await upsertUser(client, fixture.users.operationsManager); + await upsertUser(client, fixture.users.vendorManager); + await upsertUser(client, fixture.users.staffAna); + + await client.query( + ` + INSERT INTO tenants (id, slug, name, status, metadata) + VALUES ($1, $2, $3, 'ACTIVE', $4::jsonb) + `, + [fixture.tenant.id, fixture.tenant.slug, fixture.tenant.name, JSON.stringify({ seededBy: 'seed-v2-demo-data' })] + ); + + await client.query( + ` + INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata) + VALUES + ($1, $2, 'ACTIVE', 'admin', '{"persona":"business_owner"}'::jsonb), + ($1, $3, 'ACTIVE', 'manager', '{"persona":"ops_manager"}'::jsonb), + ($1, $4, 'ACTIVE', 'manager', '{"persona":"vendor_manager"}'::jsonb), + ($1, $5, 'ACTIVE', 'member', '{"persona":"staff"}'::jsonb) + `, + [ + fixture.tenant.id, + fixture.users.businessOwner.id, + fixture.users.operationsManager.id, + fixture.users.vendorManager.id, + fixture.users.staffAna.id, + ] + ); + + await client.query( + ` + INSERT INTO businesses ( + id, tenant_id, slug, business_name, status, contact_name, contact_email, contact_phone, metadata + ) + VALUES ($1, $2, $3, $4, 'ACTIVE', $5, $6, $7, $8::jsonb) + `, + [ + fixture.business.id, + fixture.tenant.id, + fixture.business.slug, + fixture.business.name, + 'Legendary Client Manager', + fixture.users.businessOwner.email, + '+15550001001', + JSON.stringify({ segment: 'buyer', seeded: true }), + ] + ); + + await client.query( + ` + INSERT INTO business_memberships ( + tenant_id, business_id, user_id, membership_status, business_role, metadata + ) + VALUES + ($1, $2, $3, 'ACTIVE', 'owner', '{"persona":"client_owner"}'::jsonb), + ($1, $2, $4, 'ACTIVE', 'manager', '{"persona":"client_ops"}'::jsonb) + `, + [fixture.tenant.id, fixture.business.id, fixture.users.businessOwner.id, fixture.users.operationsManager.id] + ); + + await client.query( + ` + INSERT INTO vendors ( + id, tenant_id, slug, company_name, status, contact_name, contact_email, contact_phone, metadata + ) + VALUES ($1, $2, $3, $4, 'ACTIVE', $5, $6, $7, $8::jsonb) + `, + [ + fixture.vendor.id, + fixture.tenant.id, + fixture.vendor.slug, + fixture.vendor.name, + 'Vendor Manager', + fixture.users.vendorManager.email, + '+15550001002', + JSON.stringify({ kind: 'internal_pool', seeded: true }), + ] + ); + + await client.query( + ` + INSERT INTO vendor_memberships ( + tenant_id, vendor_id, user_id, membership_status, vendor_role, metadata + ) + VALUES ($1, $2, $3, 'ACTIVE', 'owner', '{"persona":"vendor_owner"}'::jsonb) + `, + [fixture.tenant.id, fixture.vendor.id, fixture.users.vendorManager.id] + ); + + await client.query( + ` + INSERT INTO cost_centers (id, tenant_id, business_id, code, name, status, metadata) + VALUES ($1, $2, $3, 'CAFE_OPS', $4, 'ACTIVE', '{"seeded":true}'::jsonb) + `, + [fixture.costCenters.cafeOps.id, fixture.tenant.id, fixture.business.id, fixture.costCenters.cafeOps.name] + ); + + await client.query( + ` + INSERT INTO roles_catalog (id, tenant_id, code, name, status, metadata) + VALUES + ($1, $3, $4, $5, 'ACTIVE', '{}'::jsonb), + ($2, $3, $6, $7, 'ACTIVE', '{}'::jsonb) + `, + [ + fixture.roles.barista.id, + fixture.roles.captain.id, + fixture.tenant.id, + fixture.roles.barista.code, + fixture.roles.barista.name, + fixture.roles.captain.code, + fixture.roles.captain.name, + ] + ); + + await client.query( + ` + INSERT INTO staffs ( + id, tenant_id, user_id, full_name, email, phone, status, primary_role, onboarding_status, + average_rating, rating_count, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, 'ACTIVE', $7, 'COMPLETED', 4.50, 1, $8::jsonb) + `, + [ + fixture.staff.ana.id, + fixture.tenant.id, + fixture.users.staffAna.id, + fixture.staff.ana.fullName, + fixture.staff.ana.email, + fixture.staff.ana.phone, + fixture.staff.ana.primaryRole, + JSON.stringify({ + favoriteCandidate: true, + seeded: true, + firstName: 'Ana', + lastName: 'Barista', + bio: 'Experienced barista and event staffing professional.', + preferredLocations: [ + { + city: 'Mountain View', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + }, + ], + maxDistanceMiles: 20, + industries: ['CATERING', 'CAFE'], + skills: ['BARISTA', 'CUSTOMER_SERVICE'], + emergencyContact: { + name: 'Maria Barista', + phone: '+15550007777', + }, + }), + ] + ); + + await client.query( + ` + INSERT INTO staff_roles (staff_id, role_id, is_primary) + VALUES ($1, $2, TRUE) + `, + [fixture.staff.ana.id, fixture.roles.barista.id] + ); + + await client.query( + ` + INSERT INTO workforce (id, tenant_id, vendor_id, staff_id, workforce_number, employment_type, status, metadata) + VALUES ($1, $2, $3, $4, $5, 'TEMP', 'ACTIVE', $6::jsonb) + `, + [ + fixture.workforce.ana.id, + fixture.tenant.id, + fixture.vendor.id, + fixture.staff.ana.id, + fixture.workforce.ana.workforceNumber, + JSON.stringify({ source: 'seed-v2-demo' }), + ] + ); + + await client.query( + ` + INSERT INTO staff_availability ( + id, tenant_id, staff_id, day_of_week, availability_status, time_slots, metadata + ) + VALUES + ($1, $3, $4, 1, 'PARTIAL', '[{"start":"08:00","end":"18:00"}]'::jsonb, '{"seeded":true}'::jsonb), + ($2, $3, $4, 5, 'PARTIAL', '[{"start":"09:00","end":"17:00"}]'::jsonb, '{"seeded":true}'::jsonb) + `, + [fixture.availability.monday.id, fixture.availability.friday.id, fixture.tenant.id, fixture.staff.ana.id] + ); + + await client.query( + ` + INSERT INTO staff_benefits ( + id, tenant_id, staff_id, benefit_type, title, status, tracked_hours, target_hours, metadata + ) + VALUES ($1, $2, $3, 'COMMUTER', $4, 'ACTIVE', 32, 40, '{"description":"Commuter stipend unlocked after 40 hours"}'::jsonb) + `, + [fixture.benefits.commuter.id, fixture.tenant.id, fixture.staff.ana.id, fixture.benefits.commuter.title] + ); + + await client.query( + ` + INSERT INTO emergency_contacts ( + id, tenant_id, staff_id, full_name, phone, relationship_type, is_primary, metadata + ) + VALUES ($1, $2, $3, 'Maria Barista', '+15550007777', 'SIBLING', TRUE, '{"seeded":true}'::jsonb) + `, + [fixture.emergencyContacts.primary.id, fixture.tenant.id, fixture.staff.ana.id] + ); + + await client.query( + ` + INSERT INTO clock_points ( + id, tenant_id, business_id, cost_center_id, label, address, latitude, longitude, + geofence_radius_meters, nfc_tag_uid, default_clock_in_mode, allow_clock_in_override, status, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'ACTIVE', $13::jsonb) + `, + [ + fixture.clockPoint.id, + fixture.tenant.id, + fixture.business.id, + fixture.costCenters.cafeOps.id, + fixture.clockPoint.label, + fixture.clockPoint.address, + fixture.clockPoint.latitude, + fixture.clockPoint.longitude, + fixture.clockPoint.geofenceRadiusMeters, + fixture.clockPoint.nfcTagUid, + fixture.clockPoint.defaultClockInMode, + fixture.clockPoint.allowClockInOverride, + JSON.stringify({ city: 'Mountain View', state: 'CA', zipCode: '94043', seeded: true }), + ] + ); + + await client.query( + ` + INSERT INTO hub_managers (id, tenant_id, hub_id, business_membership_id) + SELECT $1, $2, $3, bm.id + FROM business_memberships bm + WHERE bm.business_id = $4 + AND bm.user_id = $5 + `, + [ + fixture.hubManagers.opsLead.id, + fixture.tenant.id, + fixture.clockPoint.id, + fixture.business.id, + fixture.users.operationsManager.id, + ] + ); + + await client.query( + ` + INSERT INTO orders ( + id, tenant_id, business_id, vendor_id, order_number, title, description, status, service_type, + starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata + ) + VALUES + ($1, $3, $4, $5, $6, $7, 'Open order for live v2 commands', 'OPEN', 'EVENT', $8, $9, 'Google Cafe', $10, $11, $12, 'Use this order for live smoke and frontend reads', $13, '{"slice":"open","orderType":"ONE_TIME"}'::jsonb), + ($2, $3, $4, $5, $14, $15, 'Completed order for favorites, reviews, invoices, and attendance history', 'COMPLETED', 'CATERING', $16, $17, 'Google Catering', $10, $11, $12, 'Completed historical example', $13, '{"slice":"completed","orderType":"ONE_TIME"}'::jsonb) + `, + [ + fixture.orders.open.id, + fixture.orders.completed.id, + fixture.tenant.id, + fixture.business.id, + fixture.vendor.id, + fixture.orders.open.number, + fixture.orders.open.title, + openStartsAt, + openEndsAt, + fixture.clockPoint.address, + fixture.clockPoint.latitude, + fixture.clockPoint.longitude, + fixture.users.businessOwner.id, + fixture.orders.completed.number, + fixture.orders.completed.title, + completedStartsAt, + completedEndsAt, + ] + ); + + await client.query( + ` + INSERT INTO orders ( + id, tenant_id, business_id, vendor_id, order_number, title, description, status, service_type, + starts_at, ends_at, location_name, location_address, latitude, longitude, notes, created_by_user_id, metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, 'Active order used to populate assigned, available, cancelled, and no-show shift states', + 'ACTIVE', 'RESTAURANT', $7, $8, 'Google Cafe', $9, $10, $11, 'Mixed state scenario order', $12, + '{"slice":"active","orderType":"ONE_TIME"}'::jsonb + ) + `, + [ + fixture.orders.active.id, + fixture.tenant.id, + fixture.business.id, + fixture.vendor.id, + fixture.orders.active.number, + fixture.orders.active.title, + assignedStartsAt, + availableEndsAt, + fixture.clockPoint.address, + fixture.clockPoint.latitude, + fixture.clockPoint.longitude, + fixture.users.operationsManager.id, + ] + ); + + await client.query( + ` + INSERT INTO shifts ( + id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone, + location_name, location_address, latitude, longitude, geofence_radius_meters, clock_in_mode, allow_clock_in_override, + required_workers, assigned_workers, notes, metadata + ) + VALUES + ($1, $3, $5, $7, $9, $11, $13, $15, 'OPEN', $17, $18, 'America/Los_Angeles', 'Google Cafe', $19, $21, $22, $23, NULL, NULL, 1, 0, 'Open staffing need', '{"slice":"open"}'::jsonb), + ($2, $4, $6, $8, $10, $12, $14, $16, 'COMPLETED', $20, $24, 'America/Los_Angeles', 'Google Catering', $19, $21, $22, $23, NULL, NULL, 1, 1, 'Completed staffed shift', '{"slice":"completed"}'::jsonb) + `, + [ + fixture.shifts.open.id, + fixture.shifts.completed.id, + fixture.tenant.id, + fixture.tenant.id, + fixture.orders.open.id, + fixture.orders.completed.id, + fixture.business.id, + fixture.business.id, + fixture.vendor.id, + fixture.vendor.id, + fixture.clockPoint.id, + fixture.clockPoint.id, + fixture.shifts.open.code, + fixture.shifts.completed.code, + fixture.shifts.open.title, + fixture.shifts.completed.title, + openStartsAt, + openEndsAt, + fixture.clockPoint.address, + completedStartsAt, + fixture.clockPoint.latitude, + fixture.clockPoint.longitude, + fixture.clockPoint.geofenceRadiusMeters, + completedEndsAt, + ] + ); + + await client.query( + ` + INSERT INTO shifts ( + id, tenant_id, order_id, business_id, vendor_id, clock_point_id, shift_code, title, status, starts_at, ends_at, timezone, + location_name, location_address, latitude, longitude, geofence_radius_meters, clock_in_mode, allow_clock_in_override, + required_workers, assigned_workers, notes, metadata + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, 'OPEN', $9, $10, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Available shift for staff marketplace', '{"slice":"available"}'::jsonb), + ($15, $2, $3, $4, $5, $6, $16, $17, 'ASSIGNED', $18, $19, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, $30, $31, 1, 1, 'Assigned shift waiting for staff confirmation', '{"slice":"assigned"}'::jsonb), + ($20, $2, $3, $4, $5, $6, $21, $22, 'CANCELLED', $23, $24, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, NULL, NULL, 1, 0, 'Cancelled shift history sample', '{"slice":"cancelled"}'::jsonb), + ($25, $2, $3, $4, $5, $6, $26, $27, 'COMPLETED', $28, $29, 'America/Los_Angeles', 'Google Cafe', $11, $12, $13, $14, 'GEO_REQUIRED', TRUE, 1, 0, 'No-show historical sample', '{"slice":"no_show"}'::jsonb) + `, + [ + fixture.shifts.available.id, + fixture.tenant.id, + fixture.orders.active.id, + fixture.business.id, + fixture.vendor.id, + fixture.clockPoint.id, + fixture.shifts.available.code, + fixture.shifts.available.title, + availableStartsAt, + availableEndsAt, + fixture.clockPoint.address, + fixture.clockPoint.latitude, + fixture.clockPoint.longitude, + fixture.clockPoint.geofenceRadiusMeters, + fixture.shifts.assigned.id, + fixture.shifts.assigned.code, + fixture.shifts.assigned.title, + assignedStartsAt, + assignedEndsAt, + fixture.shifts.cancelled.id, + fixture.shifts.cancelled.code, + fixture.shifts.cancelled.title, + cancelledStartsAt, + cancelledEndsAt, + fixture.shifts.noShow.id, + fixture.shifts.noShow.code, + fixture.shifts.noShow.title, + noShowStartsAt, + noShowEndsAt, + fixture.shifts.assigned.clockInMode, + fixture.shifts.assigned.allowClockInOverride, + ] + ); + + await client.query( + ` + INSERT INTO shift_roles ( + id, shift_id, role_id, role_code, role_name, workers_needed, assigned_count, pay_rate_cents, bill_rate_cents, metadata + ) + VALUES + ($1, $2, $3, $4, $5, 1, 0, 2200, 3500, '{"slice":"open"}'::jsonb), + ($6, $7, $3, $4, $5, 1, 1, 2200, 3500, '{"slice":"completed"}'::jsonb) + `, + [ + fixture.shiftRoles.openBarista.id, + fixture.shifts.open.id, + fixture.roles.barista.id, + fixture.roles.barista.code, + fixture.roles.barista.name, + fixture.shiftRoles.completedBarista.id, + fixture.shifts.completed.id, + ] + ); + + await client.query( + ` + INSERT INTO shift_roles ( + id, shift_id, role_id, role_code, role_name, workers_needed, assigned_count, pay_rate_cents, bill_rate_cents, metadata + ) + VALUES + ($1, $2, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"available"}'::jsonb), + ($3, $4, $7, $8, $9, 1, 1, 2300, 3600, '{"slice":"assigned"}'::jsonb), + ($5, $6, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"cancelled"}'::jsonb), + ($10, $11, $7, $8, $9, 1, 0, 2200, 3500, '{"slice":"no_show"}'::jsonb) + `, + [ + fixture.shiftRoles.availableBarista.id, + fixture.shifts.available.id, + fixture.shiftRoles.assignedBarista.id, + fixture.shifts.assigned.id, + fixture.shiftRoles.cancelledBarista.id, + fixture.shifts.cancelled.id, + fixture.roles.barista.id, + fixture.roles.barista.code, + fixture.roles.barista.name, + fixture.shiftRoles.noShowBarista.id, + fixture.shifts.noShow.id, + ] + ); + + await client.query( + ` + INSERT INTO applications ( + id, tenant_id, shift_id, shift_role_id, staff_id, status, origin, applied_at, metadata + ) + VALUES ($1, $2, $3, $4, $5, 'PENDING', 'STAFF', NOW(), '{"slice":"open"}'::jsonb) + `, + [ + fixture.applications.openAna.id, + fixture.tenant.id, + fixture.shifts.open.id, + fixture.shiftRoles.openBarista.id, + fixture.staff.ana.id, + ] + ); + + await client.query( + ` + INSERT INTO assignments ( + id, tenant_id, business_id, vendor_id, shift_id, shift_role_id, workforce_id, staff_id, status, + assigned_at, accepted_at, checked_in_at, checked_out_at, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'COMPLETED', $9, $10, $11, $12, '{"slice":"completed"}'::jsonb) + `, + [ + fixture.assignments.completedAna.id, + fixture.tenant.id, + fixture.business.id, + fixture.vendor.id, + fixture.shifts.completed.id, + fixture.shiftRoles.completedBarista.id, + fixture.workforce.ana.id, + fixture.staff.ana.id, + completedStartsAt, + completedStartsAt, + checkedInAt, + checkedOutAt, + ] + ); + + await client.query( + ` + INSERT INTO assignments ( + id, tenant_id, business_id, vendor_id, shift_id, shift_role_id, workforce_id, staff_id, status, + assigned_at, accepted_at, checked_in_at, checked_out_at, metadata + ) + VALUES + ($1, $2, $3, $4, $5, $6, $7, $8, 'ASSIGNED', NOW(), NULL, NULL, NULL, '{"slice":"assigned"}'::jsonb), + ($9, $2, $3, $4, $10, $11, $7, $8, 'CANCELLED', NOW(), NULL, NULL, NULL, '{"slice":"cancelled","cancellationReason":"Client cancelled"}'::jsonb), + ($12, $2, $3, $4, $13, $14, $7, $8, 'NO_SHOW', $15, NULL, NULL, NULL, '{"slice":"no_show"}'::jsonb) + `, + [ + fixture.assignments.assignedAna.id, + fixture.tenant.id, + fixture.business.id, + fixture.vendor.id, + fixture.shifts.assigned.id, + fixture.shiftRoles.assignedBarista.id, + fixture.workforce.ana.id, + fixture.staff.ana.id, + fixture.assignments.cancelledAna.id, + fixture.shifts.cancelled.id, + fixture.shiftRoles.cancelledBarista.id, + fixture.assignments.noShowAna.id, + fixture.shifts.noShow.id, + fixture.shiftRoles.noShowBarista.id, + noShowStartsAt, + ] + ); + + await client.query( + ` + INSERT INTO attendance_events ( + tenant_id, assignment_id, shift_id, staff_id, clock_point_id, event_type, source_type, source_reference, + nfc_tag_uid, device_id, latitude, longitude, accuracy_meters, distance_to_clock_point_meters, within_geofence, + validation_status, validation_reason, captured_at, raw_payload + ) + VALUES + ($1, $2, $3, $4, $5, 'CLOCK_IN', 'NFC', 'seed', $6, 'seed-device', $7, $8, 5, 0, TRUE, 'ACCEPTED', NULL, $9, '{"seeded":true}'::jsonb), + ($1, $2, $3, $4, $5, 'CLOCK_OUT', 'NFC', 'seed', $6, 'seed-device', $7, $8, 5, 0, TRUE, 'ACCEPTED', NULL, $10, '{"seeded":true}'::jsonb) + `, + [ + fixture.tenant.id, + fixture.assignments.completedAna.id, + fixture.shifts.completed.id, + fixture.staff.ana.id, + fixture.clockPoint.id, + fixture.clockPoint.nfcTagUid, + fixture.clockPoint.latitude, + fixture.clockPoint.longitude, + checkedInAt, + checkedOutAt, + ] + ); + + const attendanceEvents = await client.query( + ` + SELECT id, event_type + FROM attendance_events + WHERE assignment_id = $1 + ORDER BY captured_at ASC + `, + [fixture.assignments.completedAna.id] + ); + + await client.query( + ` + INSERT INTO attendance_sessions ( + id, tenant_id, assignment_id, staff_id, clock_in_event_id, clock_out_event_id, status, + check_in_at, check_out_at, worked_minutes, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, 'CLOSED', $7, $8, 435, '{"seeded":true}'::jsonb) + `, + [ + '95f6017c-256c-4eb5-8033-eb942f018001', + fixture.tenant.id, + fixture.assignments.completedAna.id, + fixture.staff.ana.id, + attendanceEvents.rows.find((row) => row.event_type === 'CLOCK_IN')?.id, + attendanceEvents.rows.find((row) => row.event_type === 'CLOCK_OUT')?.id, + checkedInAt, + checkedOutAt, + ] + ); + + await client.query( + ` + INSERT INTO timesheets ( + id, tenant_id, assignment_id, staff_id, status, regular_minutes, overtime_minutes, break_minutes, gross_pay_cents, metadata + ) + VALUES ($1, $2, $3, $4, 'APPROVED', 420, 15, 30, 15950, '{"seeded":true}'::jsonb) + `, + [fixture.timesheets.completedAna.id, fixture.tenant.id, fixture.assignments.completedAna.id, fixture.staff.ana.id] + ); + + await client.query( + ` + INSERT INTO documents (id, tenant_id, document_type, name, required_for_role_code, metadata) + VALUES + ($1, $2, 'GOVERNMENT_ID', $3, $10, '{"seeded":true,"description":"State ID or passport","required":true}'::jsonb), + ($4, $2, 'CERTIFICATION', $5, $10, '{"seeded":true}'::jsonb), + ($6, $2, 'ATTIRE', $7, $10, '{"seeded":true,"description":"Upload a photo of your black shirt","required":true}'::jsonb), + ($8, $2, 'TAX_FORM', $9, $10, '{"seeded":true}'::jsonb), + ($11, $2, 'TAX_FORM', $12, $10, '{"seeded":true}'::jsonb) + `, + [ + fixture.documents.governmentId.id, + fixture.tenant.id, + fixture.documents.governmentId.name, + fixture.documents.foodSafety.id, + fixture.documents.foodSafety.name, + fixture.documents.attireBlackShirt.id, + fixture.documents.attireBlackShirt.name, + fixture.documents.taxFormI9.id, + fixture.documents.taxFormI9.name, + fixture.roles.barista.code, + fixture.documents.taxFormW4.id, + fixture.documents.taxFormW4.name, + ] + ); + + await client.query( + ` + INSERT INTO staff_documents ( + id, tenant_id, staff_id, document_id, file_uri, status, expires_at, metadata + ) + VALUES + ($1, $2, $3, $4, $5, 'PENDING', $6, '{"seeded":true,"verificationStatus":"PENDING_REVIEW"}'::jsonb), + ($7, $2, $3, $8, $9, 'VERIFIED', $10, '{"seeded":true,"verificationStatus":"APPROVED"}'::jsonb), + ($11, $2, $3, $12, $13, 'VERIFIED', NULL, '{"seeded":true,"verificationStatus":"APPROVED"}'::jsonb), + ($14, $2, $3, $15, $16, 'VERIFIED', NULL, '{"seeded":true,"formStatus":"SUBMITTED","fields":{"ssnLast4":"1234","filingStatus":"single"}}'::jsonb), + ($17, $2, $3, $18, $19, 'PENDING', NULL, '{"seeded":true,"formStatus":"DRAFT","fields":{"section1Complete":true}}'::jsonb) + `, + [ + fixture.staffDocuments.governmentId.id, + fixture.tenant.id, + fixture.staff.ana.id, + fixture.documents.governmentId.id, + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/government-id-front.jpg`, + hoursFromNow(24 * 365), + fixture.staffDocuments.foodSafety.id, + fixture.documents.foodSafety.id, + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`, + hoursFromNow(24 * 180), + fixture.staffDocuments.attireBlackShirt.id, + fixture.documents.attireBlackShirt.id, + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/black-shirt.jpg`, + fixture.staffDocuments.taxFormW4.id, + fixture.documents.taxFormW4.id, + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/w4-form.pdf`, + fixture.staffDocuments.taxFormI9.id, + fixture.documents.taxFormI9.id, + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/i9-form.pdf`, + ] + ); + + await client.query( + ` + INSERT INTO certificates ( + id, tenant_id, staff_id, certificate_type, certificate_number, issued_at, expires_at, status, file_uri, metadata + ) + VALUES ($1, $2, $3, 'FOOD_SAFETY', 'FH-ANA-2026', $4, $5, 'VERIFIED', $6, $7::jsonb) + `, + [ + fixture.certificates.foodSafety.id, + fixture.tenant.id, + fixture.staff.ana.id, + hoursFromNow(-24 * 30), + hoursFromNow(24 * 180), + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-safety-certificate.pdf`, + JSON.stringify({ + seeded: true, + name: 'Food Safety Certificate', + issuer: 'ServSafe', + verificationStatus: 'APPROVED', + }), + ] + ); + + await client.query( + ` + INSERT INTO verification_jobs ( + tenant_id, staff_id, document_id, type, file_uri, status, idempotency_key, + provider_name, provider_reference, confidence, reasons, extracted, review, metadata + ) + VALUES ( + $1, $2, $3, 'certification', $4, 'APPROVED', 'seed-certification-job', + 'seed', 'seed-certification-provider', 0.980, '["Verified by seed"]'::jsonb, + '{"certificateType":"FOOD_SAFETY"}'::jsonb, '{"decision":"APPROVED"}'::jsonb, '{"seeded":true}'::jsonb + ) + `, + [ + fixture.tenant.id, + fixture.staff.ana.id, + fixture.documents.foodSafety.id, + `gs://krow-workforce-dev-v2-private/uploads/${fixture.staff.ana.id}/food-handler-card.pdf`, + ] + ); + + await client.query( + ` + INSERT INTO accounts ( + id, tenant_id, owner_type, owner_business_id, owner_vendor_id, owner_staff_id, + provider_name, provider_reference, last4, is_primary, metadata + ) + VALUES + ($1, $3, 'BUSINESS', $4, NULL, NULL, 'stripe', 'ba_business_demo', '6789', TRUE, '{"seeded":true,"accountType":"CHECKING","routingNumberMasked":"*****0001"}'::jsonb), + ($2, $3, 'STAFF', NULL, NULL, $5, 'stripe', 'ba_staff_demo', '4321', TRUE, '{"seeded":true,"accountType":"CHECKING","routingNumberMasked":"*****0002"}'::jsonb) + `, + [ + fixture.accounts.businessPrimary.id, + fixture.accounts.staffPrimary.id, + fixture.tenant.id, + fixture.business.id, + fixture.staff.ana.id, + ] + ); + + await client.query( + ` + INSERT INTO invoices ( + id, tenant_id, order_id, business_id, vendor_id, invoice_number, status, currency_code, + subtotal_cents, tax_cents, total_cents, due_at, metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, 'PENDING_REVIEW', 'USD', 15250, 700, 15950, $7, '{"seeded":true,"savingsCents":1250}'::jsonb) + `, + [ + fixture.invoices.completed.id, + fixture.tenant.id, + fixture.orders.completed.id, + fixture.business.id, + fixture.vendor.id, + fixture.invoices.completed.number, + invoiceDueAt, + ] + ); + + await client.query( + ` + INSERT INTO recent_payments ( + id, tenant_id, invoice_id, assignment_id, staff_id, status, amount_cents, process_date, metadata + ) + VALUES ($1, $2, $3, $4, $5, 'PENDING', 15950, NULL, '{"seeded":true}'::jsonb) + `, + [ + fixture.recentPayments.completed.id, + fixture.tenant.id, + fixture.invoices.completed.id, + fixture.assignments.completedAna.id, + fixture.staff.ana.id, + ] + ); + + await client.query( + ` + INSERT INTO staff_favorites (id, tenant_id, business_id, staff_id, created_by_user_id, created_at) + VALUES ($1, $2, $3, $4, $5, NOW()) + `, + [ + fixture.favorites.ana.id, + fixture.tenant.id, + fixture.business.id, + fixture.staff.ana.id, + fixture.users.businessOwner.id, + ] + ); + + await client.query( + ` + INSERT INTO staff_reviews ( + id, tenant_id, business_id, staff_id, assignment_id, reviewer_user_id, rating, review_text, tags, created_at, updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, 5, 'Reliable, on time, and client friendly.', '["reliable","favorite"]'::jsonb, NOW(), NOW()) + `, + [ + fixture.reviews.anaCompleted.id, + fixture.tenant.id, + fixture.business.id, + fixture.staff.ana.id, + fixture.assignments.completedAna.id, + fixture.users.businessOwner.id, + ] + ); + + await client.query( + ` + INSERT INTO domain_events (tenant_id, aggregate_type, aggregate_id, sequence, event_type, actor_user_id, payload) + VALUES + ($1, 'order', $2, 1, 'ORDER_CREATED', $3, '{"seeded":true}'::jsonb), + ($1, 'assignment', $4, 1, 'STAFF_ASSIGNED', $3, '{"seeded":true}'::jsonb) + `, + [ + fixture.tenant.id, + fixture.orders.completed.id, + fixture.users.businessOwner.id, + fixture.assignments.completedAna.id, + ] + ); + + await client.query( + ` + INSERT INTO location_stream_batches ( + id, tenant_id, business_id, vendor_id, shift_id, assignment_id, staff_id, actor_user_id, + source_type, device_id, object_uri, point_count, out_of_geofence_count, missing_coordinate_count, + max_distance_to_clock_point_meters, started_at, ended_at, metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, 'GEO', 'seed-device', + $9, 4, 2, 0, 910, $10, $11, '{"seeded":true,"source":"seed-v2-demo"}'::jsonb + ) + `, + [ + fixture.locationStreamBatches.noShowSample.id, + fixture.tenant.id, + fixture.business.id, + fixture.vendor.id, + fixture.shifts.noShow.id, + fixture.assignments.noShowAna.id, + fixture.staff.ana.id, + fixture.users.staffAna.id, + `gs://krow-workforce-dev-v2-private/location-streams/${fixture.tenant.id}/${fixture.staff.ana.id}/${fixture.assignments.noShowAna.id}/${fixture.locationStreamBatches.noShowSample.id}.json`, + hoursFromNow(-18.25), + hoursFromNow(-17.75), + ] + ); + + await client.query( + ` + INSERT INTO geofence_incidents ( + id, tenant_id, business_id, vendor_id, shift_id, assignment_id, staff_id, actor_user_id, location_stream_batch_id, + incident_type, severity, status, effective_clock_in_mode, source_type, device_id, + latitude, longitude, accuracy_meters, distance_to_clock_point_meters, within_geofence, + override_reason, message, occurred_at, metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, + 'OUTSIDE_GEOFENCE', 'CRITICAL', 'OPEN', 'GEO_REQUIRED', 'GEO', 'seed-device', + $10, $11, 12, 910, FALSE, NULL, 'Worker drifted outside hub geofence during active monitoring', + $12, '{"seeded":true,"outOfGeofenceCount":2}'::jsonb + ) + `, + [ + fixture.geofenceIncidents.noShowOutsideGeofence.id, + fixture.tenant.id, + fixture.business.id, + fixture.vendor.id, + fixture.shifts.noShow.id, + fixture.assignments.noShowAna.id, + fixture.staff.ana.id, + fixture.users.staffAna.id, + fixture.locationStreamBatches.noShowSample.id, + fixture.clockPoint.latitude + 0.0065, + fixture.clockPoint.longitude + 0.0065, + hoursFromNow(-17.9), + ] + ); + + await client.query( + ` + INSERT INTO notification_outbox ( + id, tenant_id, business_id, shift_id, assignment_id, related_incident_id, audience_type, + recipient_user_id, recipient_business_membership_id, channel, notification_type, priority, dedupe_key, + subject, body, payload, status, scheduled_at, created_at, updated_at + ) + SELECT + $1, $2, $3, $4, $5, $6, 'USER', + bm.user_id, bm.id, 'PUSH', 'GEOFENCE_BREACH_ALERT', 'CRITICAL', $7, + 'Worker left the workplace geofence', + 'Seeded alert for coverage incident review', + jsonb_build_object('seeded', TRUE, 'batchId', $8::text), + 'PENDING', NOW(), NOW(), NOW() + FROM business_memberships bm + WHERE bm.tenant_id = $2 + AND bm.business_id = $3 + AND bm.user_id = $9 + `, + [ + fixture.notificationOutbox.noShowManagerAlert.id, + fixture.tenant.id, + fixture.business.id, + fixture.shifts.noShow.id, + fixture.assignments.noShowAna.id, + fixture.geofenceIncidents.noShowOutsideGeofence.id, + `seed-geofence-breach:${fixture.geofenceIncidents.noShowOutsideGeofence.id}:${fixture.users.operationsManager.id}`, + fixture.locationStreamBatches.noShowSample.id, + fixture.users.operationsManager.id, + ] + ); + + await client.query('COMMIT'); + + // eslint-disable-next-line no-console + console.log(JSON.stringify({ + tenantId: fixture.tenant.id, + businessId: fixture.business.id, + vendorId: fixture.vendor.id, + staffId: fixture.staff.ana.id, + staffUserId: fixture.users.staffAna.id, + workforceId: fixture.workforce.ana.id, + openOrderId: fixture.orders.open.id, + openShiftId: fixture.shifts.open.id, + openShiftRoleId: fixture.shiftRoles.openBarista.id, + openApplicationId: fixture.applications.openAna.id, + completedOrderId: fixture.orders.completed.id, + completedAssignmentId: fixture.assignments.completedAna.id, + clockPointId: fixture.clockPoint.id, + nfcTagUid: fixture.clockPoint.nfcTagUid, + businessOwnerUid: fixture.users.businessOwner.id, + }, null, 2)); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + await pool.end(); + } +} + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exit(1); +}); diff --git a/backend/command-api/scripts/v2-demo-fixture.mjs b/backend/command-api/scripts/v2-demo-fixture.mjs new file mode 100644 index 00000000..3fd31310 --- /dev/null +++ b/backend/command-api/scripts/v2-demo-fixture.mjs @@ -0,0 +1,290 @@ +export const V2DemoFixture = { + tenant: { + id: '6d5fa42c-1f38-49be-8895-8aeb0e731001', + slug: 'legendary-event-staffing', + name: 'Legendary Event Staffing and Entertainment', + }, + users: { + businessOwner: { + id: process.env.V2_DEMO_OWNER_UID || 'alFf9mYw3uYbm7ZjeLo1KoTgFxq2', + email: process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com', + displayName: 'Legendary Demo Owner', + }, + operationsManager: { + id: 'demo-ops-manager', + email: 'ops+v2@krowd.com', + displayName: 'Wil Ops Lead', + }, + vendorManager: { + id: 'demo-vendor-manager', + email: 'vendor+v2@krowd.com', + displayName: 'Vendor Manager', + }, + staffAna: { + id: process.env.V2_DEMO_STAFF_UID || 'vwptrLl5S2Z598WP93cgrQEzqBg1', + email: process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com', + displayName: 'Ana Barista', + }, + }, + business: { + id: '14f4fcfb-f21f-4ba9-9328-90f794a56001', + slug: 'google-mv-cafes', + name: 'Google Mountain View Cafes', + }, + vendor: { + id: '80f8c8d3-9da8-4892-908f-4d4982af7001', + slug: 'legendary-pool-a', + name: 'Legendary Staffing Pool A', + }, + costCenters: { + cafeOps: { + id: '31db54dd-9b32-4504-9056-9c71a9f73001', + name: 'Cafe Operations', + }, + }, + roles: { + barista: { + id: '67c5010e-85f0-4f6b-99b7-167c9afdf001', + code: 'BARISTA', + name: 'Barista', + }, + captain: { + id: '67c5010e-85f0-4f6b-99b7-167c9afdf002', + code: 'CAPTAIN', + name: 'Captain', + }, + }, + staff: { + ana: { + id: '4b7dff1a-1856-4d59-b450-5a6736461001', + fullName: 'Ana Barista', + email: 'ana.barista+v2@krowd.com', + phone: '+15557654321', + primaryRole: 'BARISTA', + }, + }, + workforce: { + ana: { + id: '4cc1d34a-87c3-4426-8ee0-a24c8bcfa001', + workforceNumber: 'WF-V2-ANA-001', + }, + }, + clockPoint: { + id: 'efb80ccf-3361-49c8-bc74-ff8cd4d2e001', + label: 'Google MV Cafe Clock Point', + address: '1600 Amphitheatre Pkwy, Mountain View, CA', + latitude: 37.4221, + longitude: -122.0841, + geofenceRadiusMeters: 120, + nfcTagUid: 'NFC-DEMO-ANA-001', + defaultClockInMode: 'GEO_REQUIRED', + allowClockInOverride: true, + }, + hubManagers: { + opsLead: { + id: '3f2dfd17-e6b4-4fe4-9fea-3c91c7ca8001', + }, + }, + availability: { + monday: { + id: '887bc357-c3e0-4b2c-a174-bf27d6902001', + }, + friday: { + id: '887bc357-c3e0-4b2c-a174-bf27d6902002', + }, + }, + benefits: { + commuter: { + id: 'dbd28438-66b0-452f-a5fc-dd0f3ea61001', + title: 'Commuter Support', + }, + }, + orders: { + open: { + id: 'b6132d7a-45c3-4879-b349-46b2fd518001', + number: 'ORD-V2-OPEN-1001', + title: 'Morning cafe staffing', + }, + completed: { + id: 'b6132d7a-45c3-4879-b349-46b2fd518002', + number: 'ORD-V2-COMP-1002', + title: 'Completed catering shift', + }, + active: { + id: 'b6132d7a-45c3-4879-b349-46b2fd518003', + number: 'ORD-V2-ACT-1003', + title: 'Live staffing operations', + }, + }, + shifts: { + open: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954001', + code: 'SHIFT-V2-OPEN-1', + title: 'Open breakfast shift', + }, + completed: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954002', + code: 'SHIFT-V2-COMP-1', + title: 'Completed catering shift', + }, + available: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954003', + code: 'SHIFT-V2-OPEN-2', + title: 'Available lunch shift', + }, + assigned: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954004', + code: 'SHIFT-V2-ASSIGNED-1', + title: 'Assigned espresso shift', + clockInMode: 'GEO_REQUIRED', + allowClockInOverride: true, + }, + cancelled: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954005', + code: 'SHIFT-V2-CANCELLED-1', + title: 'Cancelled hospitality shift', + }, + noShow: { + id: '6e7dadad-99e4-45bb-b0da-7bb617954006', + code: 'SHIFT-V2-NOSHOW-1', + title: 'No-show breakfast shift', + }, + }, + shiftRoles: { + openBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b001', + }, + completedBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b002', + }, + availableBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b003', + }, + assignedBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b004', + }, + cancelledBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b005', + }, + noShowBarista: { + id: '4dd35b2b-4aaf-4c28-a91f-7bda05e2b006', + }, + }, + applications: { + openAna: { + id: 'd70d6441-6d0c-4fdb-9a29-c9d9e0c34001', + }, + }, + assignments: { + completedAna: { + id: 'f1d3f738-a132-4863-b222-4f9cb25aa001', + }, + assignedAna: { + id: 'f1d3f738-a132-4863-b222-4f9cb25aa002', + }, + cancelledAna: { + id: 'f1d3f738-a132-4863-b222-4f9cb25aa003', + }, + noShowAna: { + id: 'f1d3f738-a132-4863-b222-4f9cb25aa004', + }, + }, + timesheets: { + completedAna: { + id: '41ea4057-0c55-4907-b525-07315b2b6001', + }, + }, + invoices: { + completed: { + id: '1455e15b-77f9-4c66-b2a8-dce35f7ac001', + number: 'INV-V2-2001', + }, + }, + recentPayments: { + completed: { + id: 'be6f736b-e945-4676-a73d-2912c7575001', + }, + }, + favorites: { + ana: { + id: 'ba5cb8fa-0be9-4ef4-a9fb-e60a8a48e001', + }, + }, + reviews: { + anaCompleted: { + id: '9b6bc737-fd69-4855-b425-6f0c2c4fd001', + }, + }, + documents: { + governmentId: { + id: 'e6fd0183-34d9-4c23-9a9a-bf98da995000', + name: 'Government ID', + }, + foodSafety: { + id: 'e6fd0183-34d9-4c23-9a9a-bf98da995001', + name: 'Food Handler Card', + }, + attireBlackShirt: { + id: 'e6fd0183-34d9-4c23-9a9a-bf98da995002', + name: 'Black Shirt', + }, + taxFormI9: { + id: 'e6fd0183-34d9-4c23-9a9a-bf98da995003', + name: 'I-9', + }, + taxFormW4: { + id: 'e6fd0183-34d9-4c23-9a9a-bf98da995004', + name: 'W-4', + }, + }, + staffDocuments: { + governmentId: { + id: '4b157236-a4b0-4c44-b199-7d4ea1f95000', + }, + foodSafety: { + id: '4b157236-a4b0-4c44-b199-7d4ea1f95001', + }, + attireBlackShirt: { + id: '4b157236-a4b0-4c44-b199-7d4ea1f95002', + }, + taxFormI9: { + id: '4b157236-a4b0-4c44-b199-7d4ea1f95003', + }, + taxFormW4: { + id: '4b157236-a4b0-4c44-b199-7d4ea1f95004', + }, + }, + certificates: { + foodSafety: { + id: 'df6452dc-4ec7-4d54-876d-26bf8ce5b001', + }, + }, + emergencyContacts: { + primary: { + id: '8bb1e0c0-59bb-4ce7-8f0f-27674e0b2001', + }, + }, + accounts: { + businessPrimary: { + id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe001', + }, + staffPrimary: { + id: '5d98e0ba-8e89-4ffb-aafd-df6bbe2fe002', + }, + }, + locationStreamBatches: { + noShowSample: { + id: '7184a512-b5b2-46b7-a8e0-f4a04bb8f001', + }, + }, + geofenceIncidents: { + noShowOutsideGeofence: { + id: '8174a512-b5b2-46b7-a8e0-f4a04bb8f001', + }, + }, + notificationOutbox: { + noShowManagerAlert: { + id: '9174a512-b5b2-46b7-a8e0-f4a04bb8f001', + }, + }, +}; diff --git a/backend/command-api/sql/v2/001_v2_domain_foundation.sql b/backend/command-api/sql/v2/001_v2_domain_foundation.sql new file mode 100644 index 00000000..5d871caf --- /dev/null +++ b/backend/command-api/sql/v2/001_v2_domain_foundation.sql @@ -0,0 +1,639 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INACTIVE')), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT, + display_name TEXT, + phone TEXT, + status TEXT NOT NULL DEFAULT 'ACTIVE' CHECK (status IN ('ACTIVE', 'INVITED', 'DISABLED')), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email_unique + ON users (LOWER(email)) + WHERE email IS NOT NULL; + +CREATE TABLE IF NOT EXISTS tenant_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + invited_email TEXT, + membership_status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')), + base_role TEXT NOT NULL DEFAULT 'member' + CHECK (base_role IN ('admin', 'manager', 'member', 'viewer')), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_tenant_membership_identity + CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_memberships_tenant_user + ON tenant_memberships (tenant_id, user_id) + WHERE user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tenant_memberships_tenant_invited_email + ON tenant_memberships (tenant_id, LOWER(invited_email)) + WHERE invited_email IS NOT NULL; + +CREATE TABLE IF NOT EXISTS businesses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + business_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')), + contact_name TEXT, + contact_email TEXT, + contact_phone TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_businesses_tenant_slug + ON businesses (tenant_id, slug); + +CREATE TABLE IF NOT EXISTS business_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + invited_email TEXT, + membership_status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')), + business_role TEXT NOT NULL DEFAULT 'member' + CHECK (business_role IN ('owner', 'manager', 'member', 'viewer')), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_business_membership_identity + CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_business_memberships_business_user + ON business_memberships (business_id, user_id) + WHERE user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_business_memberships_business_invited_email + ON business_memberships (business_id, LOWER(invited_email)) + WHERE invited_email IS NOT NULL; + +CREATE TABLE IF NOT EXISTS vendors ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + company_name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')), + contact_name TEXT, + contact_email TEXT, + contact_phone TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_vendors_tenant_slug + ON vendors (tenant_id, slug); + +CREATE TABLE IF NOT EXISTS vendor_memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + invited_email TEXT, + membership_status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (membership_status IN ('INVITED', 'ACTIVE', 'SUSPENDED', 'REMOVED')), + vendor_role TEXT NOT NULL DEFAULT 'member' + CHECK (vendor_role IN ('owner', 'manager', 'member', 'viewer')), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_vendor_membership_identity + CHECK (user_id IS NOT NULL OR invited_email IS NOT NULL) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_vendor_memberships_vendor_user + ON vendor_memberships (vendor_id, user_id) + WHERE user_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_vendor_memberships_vendor_invited_email + ON vendor_memberships (vendor_id, LOWER(invited_email)) + WHERE invited_email IS NOT NULL; + +CREATE TABLE IF NOT EXISTS staffs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + full_name TEXT NOT NULL, + email TEXT, + phone TEXT, + status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'INVITED', 'INACTIVE', 'BLOCKED')), + primary_role TEXT, + onboarding_status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (onboarding_status IN ('PENDING', 'IN_PROGRESS', 'COMPLETED')), + average_rating NUMERIC(3, 2) NOT NULL DEFAULT 0 CHECK (average_rating >= 0 AND average_rating <= 5), + rating_count INTEGER NOT NULL DEFAULT 0 CHECK (rating_count >= 0), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_staffs_tenant_user + ON staffs (tenant_id, user_id) + WHERE user_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS workforce ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + vendor_id UUID NOT NULL REFERENCES vendors(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + workforce_number TEXT NOT NULL, + employment_type TEXT NOT NULL + CHECK (employment_type IN ('W2', 'W1099', 'TEMP', 'CONTRACT')), + status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'INACTIVE', 'SUSPENDED')), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_workforce_vendor_staff + ON workforce (vendor_id, staff_id); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_workforce_number_tenant + ON workforce (tenant_id, workforce_number); + +CREATE TABLE IF NOT EXISTS roles_catalog ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + code TEXT NOT NULL, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'INACTIVE')), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_roles_catalog_tenant_code + ON roles_catalog (tenant_id, code); + +CREATE TABLE IF NOT EXISTS staff_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles_catalog(id) ON DELETE CASCADE, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_roles_staff_role + ON staff_roles (staff_id, role_id); + +CREATE TABLE IF NOT EXISTS clock_points ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID REFERENCES businesses(id) ON DELETE SET NULL, + label TEXT NOT NULL, + address TEXT, + latitude NUMERIC(9, 6), + longitude NUMERIC(9, 6), + geofence_radius_meters INTEGER NOT NULL DEFAULT 100 CHECK (geofence_radius_meters > 0), + nfc_tag_uid TEXT, + status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'INACTIVE')), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_clock_points_tenant_nfc_tag + ON clock_points (tenant_id, nfc_tag_uid) + WHERE nfc_tag_uid IS NOT NULL; + +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT, + vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL, + order_number TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + status TEXT NOT NULL DEFAULT 'DRAFT' + CHECK (status IN ('DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED', 'CANCELLED')), + service_type TEXT NOT NULL DEFAULT 'EVENT' + CHECK (service_type IN ('EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER')), + starts_at TIMESTAMPTZ, + ends_at TIMESTAMPTZ, + location_name TEXT, + location_address TEXT, + latitude NUMERIC(9, 6), + longitude NUMERIC(9, 6), + notes TEXT, + created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_orders_time_window CHECK (starts_at IS NULL OR ends_at IS NULL OR starts_at < ends_at) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_orders_tenant_order_number + ON orders (tenant_id, order_number); + +CREATE INDEX IF NOT EXISTS idx_orders_tenant_business_status + ON orders (tenant_id, business_id, status, created_at DESC); + +CREATE TABLE IF NOT EXISTS shifts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT, + vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL, + clock_point_id UUID REFERENCES clock_points(id) ON DELETE SET NULL, + shift_code TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'OPEN' + CHECK (status IN ('DRAFT', 'OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE', 'COMPLETED', 'CANCELLED')), + starts_at TIMESTAMPTZ NOT NULL, + ends_at TIMESTAMPTZ NOT NULL, + timezone TEXT NOT NULL DEFAULT 'UTC', + location_name TEXT, + location_address TEXT, + latitude NUMERIC(9, 6), + longitude NUMERIC(9, 6), + geofence_radius_meters INTEGER CHECK (geofence_radius_meters IS NULL OR geofence_radius_meters > 0), + required_workers INTEGER NOT NULL DEFAULT 1 CHECK (required_workers > 0), + assigned_workers INTEGER NOT NULL DEFAULT 0 CHECK (assigned_workers >= 0), + notes TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_shifts_time_window CHECK (starts_at < ends_at), + CONSTRAINT chk_shifts_assigned_workers CHECK (assigned_workers <= required_workers) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_shifts_order_shift_code + ON shifts (order_id, shift_code); + +CREATE INDEX IF NOT EXISTS idx_shifts_tenant_time + ON shifts (tenant_id, starts_at, ends_at); + +CREATE TABLE IF NOT EXISTS shift_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, + role_id UUID REFERENCES roles_catalog(id) ON DELETE SET NULL, + role_code TEXT NOT NULL, + role_name TEXT NOT NULL, + workers_needed INTEGER NOT NULL CHECK (workers_needed > 0), + assigned_count INTEGER NOT NULL DEFAULT 0 CHECK (assigned_count >= 0), + pay_rate_cents INTEGER NOT NULL DEFAULT 0 CHECK (pay_rate_cents >= 0), + bill_rate_cents INTEGER NOT NULL DEFAULT 0 CHECK (bill_rate_cents >= 0), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_shift_roles_assigned_count CHECK (assigned_count <= workers_needed) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_shift_roles_shift_role_code + ON shift_roles (shift_id, role_code); + +CREATE TABLE IF NOT EXISTS applications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, + shift_role_id UUID NOT NULL REFERENCES shift_roles(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'LATE', 'NO_SHOW', 'COMPLETED', 'REJECTED', 'CANCELLED')), + origin TEXT NOT NULL DEFAULT 'STAFF' + CHECK (origin IN ('STAFF', 'BUSINESS', 'VENDOR', 'SYSTEM')), + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_applications_shift_role_staff + ON applications (shift_role_id, staff_id); + +CREATE INDEX IF NOT EXISTS idx_applications_staff_status + ON applications (staff_id, status, applied_at DESC); + +CREATE TABLE IF NOT EXISTS assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT, + vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL, + shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, + shift_role_id UUID NOT NULL REFERENCES shift_roles(id) ON DELETE CASCADE, + workforce_id UUID NOT NULL REFERENCES workforce(id) ON DELETE RESTRICT, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT, + application_id UUID REFERENCES applications(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'ASSIGNED' + CHECK (status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED', 'CANCELLED', 'NO_SHOW')), + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + accepted_at TIMESTAMPTZ, + checked_in_at TIMESTAMPTZ, + checked_out_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_assignments_shift_role_workforce + ON assignments (shift_role_id, workforce_id); + +CREATE INDEX IF NOT EXISTS idx_assignments_staff_status + ON assignments (staff_id, status, assigned_at DESC); + +CREATE TABLE IF NOT EXISTS attendance_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT, + clock_point_id UUID REFERENCES clock_points(id) ON DELETE SET NULL, + event_type TEXT NOT NULL + CHECK (event_type IN ('CLOCK_IN', 'CLOCK_OUT', 'MANUAL_ADJUSTMENT')), + source_type TEXT NOT NULL + CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')), + source_reference TEXT, + nfc_tag_uid TEXT, + device_id TEXT, + latitude NUMERIC(9, 6), + longitude NUMERIC(9, 6), + accuracy_meters INTEGER CHECK (accuracy_meters IS NULL OR accuracy_meters >= 0), + distance_to_clock_point_meters INTEGER CHECK (distance_to_clock_point_meters IS NULL OR distance_to_clock_point_meters >= 0), + within_geofence BOOLEAN, + validation_status TEXT NOT NULL DEFAULT 'ACCEPTED' + CHECK (validation_status IN ('ACCEPTED', 'FLAGGED', 'REJECTED')), + validation_reason TEXT, + captured_at TIMESTAMPTZ NOT NULL, + raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_attendance_events_assignment_time + ON attendance_events (assignment_id, captured_at DESC); + +CREATE INDEX IF NOT EXISTS idx_attendance_events_staff_time + ON attendance_events (staff_id, captured_at DESC); + +CREATE TABLE IF NOT EXISTS attendance_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + assignment_id UUID NOT NULL UNIQUE REFERENCES assignments(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT, + clock_in_event_id UUID REFERENCES attendance_events(id) ON DELETE SET NULL, + clock_out_event_id UUID REFERENCES attendance_events(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'OPEN' + CHECK (status IN ('OPEN', 'CLOSED', 'DISPUTED')), + check_in_at TIMESTAMPTZ, + check_out_at TIMESTAMPTZ, + worked_minutes INTEGER NOT NULL DEFAULT 0 CHECK (worked_minutes >= 0), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS timesheets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + assignment_id UUID NOT NULL UNIQUE REFERENCES assignments(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT, + status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'SUBMITTED', 'APPROVED', 'REJECTED', 'PAID')), + regular_minutes INTEGER NOT NULL DEFAULT 0 CHECK (regular_minutes >= 0), + overtime_minutes INTEGER NOT NULL DEFAULT 0 CHECK (overtime_minutes >= 0), + break_minutes INTEGER NOT NULL DEFAULT 0 CHECK (break_minutes >= 0), + gross_pay_cents BIGINT NOT NULL DEFAULT 0 CHECK (gross_pay_cents >= 0), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + document_type TEXT NOT NULL, + name TEXT NOT NULL, + required_for_role_code TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_documents_tenant_type_name + ON documents (tenant_id, document_type, name); + +CREATE TABLE IF NOT EXISTS staff_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + file_uri TEXT, + status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'VERIFIED', 'REJECTED', 'EXPIRED')), + expires_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_documents_staff_document + ON staff_documents (staff_id, document_id); + +CREATE TABLE IF NOT EXISTS certificates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + certificate_type TEXT NOT NULL, + certificate_number TEXT, + issued_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'VERIFIED', 'REJECTED', 'EXPIRED')), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL, + document_id UUID REFERENCES documents(id) ON DELETE SET NULL, + type TEXT NOT NULL, + file_uri TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'PROCESSING', 'AUTO_PASS', 'AUTO_FAIL', 'NEEDS_REVIEW', 'APPROVED', 'REJECTED', 'ERROR')), + idempotency_key TEXT, + provider_name TEXT, + provider_reference TEXT, + confidence NUMERIC(4, 3), + reasons JSONB NOT NULL DEFAULT '[]'::jsonb, + extracted JSONB NOT NULL DEFAULT '{}'::jsonb, + review JSONB NOT NULL DEFAULT '{}'::jsonb, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_verification_jobs_tenant_idempotency + ON verification_jobs (tenant_id, idempotency_key) + WHERE idempotency_key IS NOT NULL; + +CREATE TABLE IF NOT EXISTS verification_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_job_id UUID NOT NULL REFERENCES verification_jobs(id) ON DELETE CASCADE, + reviewer_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + decision TEXT NOT NULL CHECK (decision IN ('APPROVED', 'REJECTED')), + note TEXT, + reason_code TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_job_id UUID NOT NULL REFERENCES verification_jobs(id) ON DELETE CASCADE, + from_status TEXT, + to_status TEXT NOT NULL, + actor_type TEXT NOT NULL, + actor_id TEXT, + details JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS accounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + owner_type TEXT NOT NULL CHECK (owner_type IN ('BUSINESS', 'VENDOR', 'STAFF')), + owner_business_id UUID REFERENCES businesses(id) ON DELETE CASCADE, + owner_vendor_id UUID REFERENCES vendors(id) ON DELETE CASCADE, + owner_staff_id UUID REFERENCES staffs(id) ON DELETE CASCADE, + provider_name TEXT NOT NULL, + provider_reference TEXT NOT NULL, + last4 TEXT, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_accounts_single_owner + CHECK ( + (owner_business_id IS NOT NULL)::INTEGER + + (owner_vendor_id IS NOT NULL)::INTEGER + + (owner_staff_id IS NOT NULL)::INTEGER = 1 + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_business + ON accounts (owner_business_id) + WHERE owner_business_id IS NOT NULL AND is_primary = TRUE; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_vendor + ON accounts (owner_vendor_id) + WHERE owner_vendor_id IS NOT NULL AND is_primary = TRUE; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_owner_primary_staff + ON accounts (owner_staff_id) + WHERE owner_staff_id IS NOT NULL AND is_primary = TRUE; + +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE RESTRICT, + vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL, + invoice_number TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('DRAFT', 'PENDING', 'PENDING_REVIEW', 'APPROVED', 'PAID', 'OVERDUE', 'DISPUTED', 'VOID')), + currency_code TEXT NOT NULL DEFAULT 'USD', + subtotal_cents BIGINT NOT NULL DEFAULT 0 CHECK (subtotal_cents >= 0), + tax_cents BIGINT NOT NULL DEFAULT 0 CHECK (tax_cents >= 0), + total_cents BIGINT NOT NULL DEFAULT 0 CHECK (total_cents >= 0), + due_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_invoices_tenant_invoice_number + ON invoices (tenant_id, invoice_number); + +CREATE TABLE IF NOT EXISTS recent_payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + invoice_id UUID NOT NULL REFERENCES invoices(id) ON DELETE CASCADE, + assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL, + staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'PROCESSING', 'PAID', 'FAILED')), + amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0), + process_date TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS staff_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + reviewer_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + rating SMALLINT NOT NULL CHECK (rating BETWEEN 1 AND 5), + review_text TEXT, + tags JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_reviews_business_assignment_staff + ON staff_reviews (business_id, assignment_id, staff_id); + +CREATE INDEX IF NOT EXISTS idx_staff_reviews_staff_created_at + ON staff_reviews (staff_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS staff_favorites ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_favorites_business_staff + ON staff_favorites (business_id, staff_id); + +CREATE TABLE IF NOT EXISTS domain_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + aggregate_type TEXT NOT NULL, + aggregate_id UUID NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + event_type TEXT NOT NULL, + actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_domain_events_aggregate_sequence + ON domain_events (tenant_id, aggregate_type, aggregate_id, sequence); diff --git a/backend/command-api/sql/v2/002_v2_mobile_support.sql b/backend/command-api/sql/v2/002_v2_mobile_support.sql new file mode 100644 index 00000000..1243a28f --- /dev/null +++ b/backend/command-api/sql/v2/002_v2_mobile_support.sql @@ -0,0 +1,64 @@ +CREATE TABLE IF NOT EXISTS cost_centers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + code TEXT, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'INACTIVE', 'ARCHIVED')), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_cost_centers_business_name + ON cost_centers (business_id, name); + +ALTER TABLE clock_points + ADD COLUMN IF NOT EXISTS cost_center_id UUID REFERENCES cost_centers(id) ON DELETE SET NULL; + +CREATE TABLE IF NOT EXISTS hub_managers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + hub_id UUID NOT NULL REFERENCES clock_points(id) ON DELETE CASCADE, + business_membership_id UUID NOT NULL REFERENCES business_memberships(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_hub_managers_hub_membership + ON hub_managers (hub_id, business_membership_id); + +CREATE TABLE IF NOT EXISTS staff_availability ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + day_of_week SMALLINT NOT NULL CHECK (day_of_week BETWEEN 0 AND 6), + availability_status TEXT NOT NULL DEFAULT 'UNAVAILABLE' + CHECK (availability_status IN ('AVAILABLE', 'UNAVAILABLE', 'PARTIAL')), + time_slots JSONB NOT NULL DEFAULT '[]'::jsonb, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_availability_staff_day + ON staff_availability (staff_id, day_of_week); + +CREATE TABLE IF NOT EXISTS staff_benefits ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + benefit_type TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'ACTIVE' + CHECK (status IN ('ACTIVE', 'INACTIVE', 'PENDING')), + tracked_hours INTEGER NOT NULL DEFAULT 0 CHECK (tracked_hours >= 0), + target_hours INTEGER NOT NULL DEFAULT 0 CHECK (target_hours >= 0), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_staff_benefits_staff_type + ON staff_benefits (staff_id, benefit_type); diff --git a/backend/command-api/sql/v2/003_v2_mobile_workflows.sql b/backend/command-api/sql/v2/003_v2_mobile_workflows.sql new file mode 100644 index 00000000..11d02bc0 --- /dev/null +++ b/backend/command-api/sql/v2/003_v2_mobile_workflows.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS emergency_contacts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE CASCADE, + full_name TEXT NOT NULL, + phone TEXT NOT NULL, + relationship_type TEXT NOT NULL, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_emergency_contacts_staff + ON emergency_contacts (staff_id, created_at DESC); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_emergency_contacts_primary_staff + ON emergency_contacts (staff_id) + WHERE is_primary = TRUE; + +ALTER TABLE assignments + DROP CONSTRAINT IF EXISTS assignments_status_check; + +ALTER TABLE assignments + ADD CONSTRAINT assignments_status_check + CHECK (status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED', 'CANCELLED', 'NO_SHOW')); + +ALTER TABLE verification_jobs + ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS subject_type TEXT, + ADD COLUMN IF NOT EXISTS subject_id TEXT; + +ALTER TABLE staff_documents + ADD COLUMN IF NOT EXISTS verification_job_id UUID REFERENCES verification_jobs(id) ON DELETE SET NULL; + +ALTER TABLE certificates + ADD COLUMN IF NOT EXISTS file_uri TEXT, + ADD COLUMN IF NOT EXISTS verification_job_id UUID REFERENCES verification_jobs(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_verification_jobs_owner + ON verification_jobs (owner_user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_verification_jobs_subject + ON verification_jobs (subject_type, subject_id, created_at DESC); diff --git a/backend/command-api/sql/v2/004_v2_attendance_policy_and_monitoring.sql b/backend/command-api/sql/v2/004_v2_attendance_policy_and_monitoring.sql new file mode 100644 index 00000000..8490c317 --- /dev/null +++ b/backend/command-api/sql/v2/004_v2_attendance_policy_and_monitoring.sql @@ -0,0 +1,155 @@ +ALTER TABLE clock_points + ADD COLUMN IF NOT EXISTS default_clock_in_mode TEXT, + ADD COLUMN IF NOT EXISTS allow_clock_in_override BOOLEAN; + +UPDATE clock_points +SET default_clock_in_mode = COALESCE(default_clock_in_mode, 'EITHER'), + allow_clock_in_override = COALESCE(allow_clock_in_override, TRUE) +WHERE default_clock_in_mode IS NULL + OR allow_clock_in_override IS NULL; + +ALTER TABLE clock_points + ALTER COLUMN default_clock_in_mode SET DEFAULT 'EITHER', + ALTER COLUMN default_clock_in_mode SET NOT NULL, + ALTER COLUMN allow_clock_in_override SET DEFAULT TRUE, + ALTER COLUMN allow_clock_in_override SET NOT NULL; + +ALTER TABLE clock_points + DROP CONSTRAINT IF EXISTS clock_points_default_clock_in_mode_check; + +ALTER TABLE clock_points + ADD CONSTRAINT clock_points_default_clock_in_mode_check + CHECK (default_clock_in_mode IN ('NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER')); + +ALTER TABLE shifts + ADD COLUMN IF NOT EXISTS clock_in_mode TEXT, + ADD COLUMN IF NOT EXISTS allow_clock_in_override BOOLEAN; + +ALTER TABLE shifts + DROP CONSTRAINT IF EXISTS shifts_clock_in_mode_check; + +ALTER TABLE shifts + ADD CONSTRAINT shifts_clock_in_mode_check + CHECK (clock_in_mode IS NULL OR clock_in_mode IN ('NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER')); + +CREATE TABLE IF NOT EXISTS location_stream_batches ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID REFERENCES businesses(id) ON DELETE SET NULL, + vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL, + shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, + assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT, + actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + source_type TEXT NOT NULL DEFAULT 'GEO' + CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')), + device_id TEXT, + object_uri TEXT, + point_count INTEGER NOT NULL DEFAULT 0 CHECK (point_count >= 0), + out_of_geofence_count INTEGER NOT NULL DEFAULT 0 CHECK (out_of_geofence_count >= 0), + missing_coordinate_count INTEGER NOT NULL DEFAULT 0 CHECK (missing_coordinate_count >= 0), + max_distance_to_clock_point_meters INTEGER CHECK (max_distance_to_clock_point_meters IS NULL OR max_distance_to_clock_point_meters >= 0), + started_at TIMESTAMPTZ, + ended_at TIMESTAMPTZ, + received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_location_stream_batches_assignment_received + ON location_stream_batches (assignment_id, received_at DESC); + +CREATE INDEX IF NOT EXISTS idx_location_stream_batches_staff_received + ON location_stream_batches (staff_id, received_at DESC); + +CREATE TABLE IF NOT EXISTS geofence_incidents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID REFERENCES businesses(id) ON DELETE SET NULL, + vendor_id UUID REFERENCES vendors(id) ON DELETE SET NULL, + shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, + assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL, + staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL, + actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + location_stream_batch_id UUID REFERENCES location_stream_batches(id) ON DELETE SET NULL, + incident_type TEXT NOT NULL + CHECK (incident_type IN ('CLOCK_IN_OVERRIDE', 'OUTSIDE_GEOFENCE', 'LOCATION_UNAVAILABLE', 'NFC_MISMATCH', 'CLOCK_IN_REJECTED')), + severity TEXT NOT NULL DEFAULT 'WARNING' + CHECK (severity IN ('INFO', 'WARNING', 'CRITICAL')), + status TEXT NOT NULL DEFAULT 'OPEN' + CHECK (status IN ('OPEN', 'ACKNOWLEDGED', 'RESOLVED')), + effective_clock_in_mode TEXT + CHECK (effective_clock_in_mode IS NULL OR effective_clock_in_mode IN ('NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER')), + source_type TEXT + CHECK (source_type IS NULL OR source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')), + nfc_tag_uid TEXT, + device_id TEXT, + latitude NUMERIC(9, 6), + longitude NUMERIC(9, 6), + accuracy_meters INTEGER CHECK (accuracy_meters IS NULL OR accuracy_meters >= 0), + distance_to_clock_point_meters INTEGER CHECK (distance_to_clock_point_meters IS NULL OR distance_to_clock_point_meters >= 0), + within_geofence BOOLEAN, + override_reason TEXT, + message TEXT, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_geofence_incidents_assignment_occurred + ON geofence_incidents (assignment_id, occurred_at DESC); + +CREATE INDEX IF NOT EXISTS idx_geofence_incidents_shift_occurred + ON geofence_incidents (shift_id, occurred_at DESC); + +CREATE INDEX IF NOT EXISTS idx_geofence_incidents_staff_occurred + ON geofence_incidents (staff_id, occurred_at DESC); + +CREATE TABLE IF NOT EXISTS notification_outbox ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + business_id UUID REFERENCES businesses(id) ON DELETE SET NULL, + shift_id UUID REFERENCES shifts(id) ON DELETE SET NULL, + assignment_id UUID REFERENCES assignments(id) ON DELETE SET NULL, + related_incident_id UUID REFERENCES geofence_incidents(id) ON DELETE SET NULL, + audience_type TEXT NOT NULL DEFAULT 'USER' + CHECK (audience_type IN ('USER', 'STAFF', 'BUSINESS_MEMBERSHIP', 'SYSTEM')), + recipient_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + recipient_staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL, + recipient_business_membership_id UUID REFERENCES business_memberships(id) ON DELETE SET NULL, + channel TEXT NOT NULL DEFAULT 'PUSH' + CHECK (channel IN ('PUSH', 'EMAIL', 'SMS', 'IN_APP', 'WEBHOOK')), + notification_type TEXT NOT NULL, + priority TEXT NOT NULL DEFAULT 'NORMAL' + CHECK (priority IN ('LOW', 'NORMAL', 'HIGH', 'CRITICAL')), + dedupe_key TEXT, + subject TEXT, + body TEXT, + payload JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'PROCESSING', 'SENT', 'FAILED', 'CANCELLED')), + attempts INTEGER NOT NULL DEFAULT 0 CHECK (attempts >= 0), + scheduled_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + sent_at TIMESTAMPTZ, + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_notification_outbox_recipient + CHECK ( + recipient_user_id IS NOT NULL + OR recipient_staff_id IS NOT NULL + OR recipient_business_membership_id IS NOT NULL + OR audience_type = 'SYSTEM' + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_outbox_dedupe + ON notification_outbox (dedupe_key); + +CREATE INDEX IF NOT EXISTS idx_notification_outbox_status_schedule + ON notification_outbox (status, scheduled_at ASC); + +CREATE INDEX IF NOT EXISTS idx_notification_outbox_recipient_user + ON notification_outbox (recipient_user_id, created_at DESC) + WHERE recipient_user_id IS NOT NULL; diff --git a/backend/command-api/sql/v2/005_v2_notification_outbox_dedupe_fix.sql b/backend/command-api/sql/v2/005_v2_notification_outbox_dedupe_fix.sql new file mode 100644 index 00000000..3e95709c --- /dev/null +++ b/backend/command-api/sql/v2/005_v2_notification_outbox_dedupe_fix.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_notification_outbox_dedupe; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_outbox_dedupe + ON notification_outbox (dedupe_key); diff --git a/backend/command-api/sql/v2/006_v2_notification_delivery_and_attendance_security.sql b/backend/command-api/sql/v2/006_v2_notification_delivery_and_attendance_security.sql new file mode 100644 index 00000000..6e749d3f --- /dev/null +++ b/backend/command-api/sql/v2/006_v2_notification_delivery_and_attendance_security.sql @@ -0,0 +1,107 @@ +CREATE TABLE IF NOT EXISTS device_push_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + staff_id UUID REFERENCES staffs(id) ON DELETE SET NULL, + business_membership_id UUID REFERENCES business_memberships(id) ON DELETE SET NULL, + vendor_membership_id UUID REFERENCES vendor_memberships(id) ON DELETE SET NULL, + provider TEXT NOT NULL DEFAULT 'FCM' + CHECK (provider IN ('FCM', 'APNS', 'WEB_PUSH')), + platform TEXT NOT NULL + CHECK (platform IN ('IOS', 'ANDROID', 'WEB')), + push_token TEXT NOT NULL, + token_hash TEXT NOT NULL, + device_id TEXT, + app_version TEXT, + app_build TEXT, + locale TEXT, + timezone TEXT, + notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE, + invalidated_at TIMESTAMPTZ, + invalidation_reason TEXT, + last_registered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_delivery_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_device_push_tokens_membership_scope + CHECK ( + business_membership_id IS NOT NULL + OR vendor_membership_id IS NOT NULL + OR staff_id IS NOT NULL + OR user_id IS NOT NULL + ) +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_device_push_tokens_provider_hash + ON device_push_tokens (provider, token_hash); + +CREATE INDEX IF NOT EXISTS idx_device_push_tokens_user_active + ON device_push_tokens (user_id, last_seen_at DESC) + WHERE invalidated_at IS NULL AND notifications_enabled = TRUE; + +CREATE INDEX IF NOT EXISTS idx_device_push_tokens_staff_active + ON device_push_tokens (staff_id, last_seen_at DESC) + WHERE staff_id IS NOT NULL AND invalidated_at IS NULL AND notifications_enabled = TRUE; + +CREATE TABLE IF NOT EXISTS notification_deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + notification_outbox_id UUID NOT NULL REFERENCES notification_outbox(id) ON DELETE CASCADE, + device_push_token_id UUID REFERENCES device_push_tokens(id) ON DELETE SET NULL, + provider TEXT NOT NULL DEFAULT 'FCM' + CHECK (provider IN ('FCM', 'APNS', 'WEB_PUSH')), + delivery_status TEXT NOT NULL + CHECK (delivery_status IN ('SIMULATED', 'SENT', 'FAILED', 'INVALID_TOKEN', 'SKIPPED')), + provider_message_id TEXT, + attempt_number INTEGER NOT NULL DEFAULT 1 CHECK (attempt_number >= 1), + error_code TEXT, + error_message TEXT, + response_payload JSONB NOT NULL DEFAULT '{}'::jsonb, + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_notification_deliveries_outbox_created + ON notification_deliveries (notification_outbox_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_notification_deliveries_token_created + ON notification_deliveries (device_push_token_id, created_at DESC) + WHERE device_push_token_id IS NOT NULL; + +CREATE TABLE IF NOT EXISTS attendance_security_proofs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + assignment_id UUID NOT NULL REFERENCES assignments(id) ON DELETE CASCADE, + shift_id UUID NOT NULL REFERENCES shifts(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staffs(id) ON DELETE RESTRICT, + actor_user_id TEXT REFERENCES users(id) ON DELETE SET NULL, + event_type TEXT NOT NULL + CHECK (event_type IN ('CLOCK_IN', 'CLOCK_OUT')), + source_type TEXT NOT NULL + CHECK (source_type IN ('NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM')), + device_id TEXT, + nfc_tag_uid TEXT, + proof_nonce TEXT, + proof_timestamp TIMESTAMPTZ, + request_fingerprint TEXT, + attestation_provider TEXT + CHECK (attestation_provider IS NULL OR attestation_provider IN ('PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK')), + attestation_token_hash TEXT, + attestation_status TEXT NOT NULL DEFAULT 'NOT_PROVIDED' + CHECK (attestation_status IN ('NOT_PROVIDED', 'RECORDED_UNVERIFIED', 'VERIFIED', 'REJECTED', 'BYPASSED')), + attestation_reason TEXT, + object_uri TEXT, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_security_proofs_nonce + ON attendance_security_proofs (tenant_id, proof_nonce) + WHERE proof_nonce IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_attendance_security_proofs_assignment_created + ON attendance_security_proofs (assignment_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_attendance_security_proofs_staff_created + ON attendance_security_proofs (staff_id, created_at DESC); diff --git a/backend/command-api/src/app.js b/backend/command-api/src/app.js index c6a72078..0c6fa44a 100644 --- a/backend/command-api/src/app.js +++ b/backend/command-api/src/app.js @@ -5,10 +5,11 @@ import { requestContext } from './middleware/request-context.js'; import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; import { healthRouter } from './routes/health.js'; import { createCommandsRouter } from './routes/commands.js'; +import { createMobileCommandsRouter } from './routes/mobile.js'; const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); -export function createApp() { +export function createApp(options = {}) { const app = express(); app.use(requestContext); @@ -21,7 +22,8 @@ export function createApp() { app.use(express.json({ limit: '2mb' })); app.use(healthRouter); - app.use('/commands', createCommandsRouter()); + app.use('/commands', createCommandsRouter(options.commandHandlers)); + app.use('/commands', createMobileCommandsRouter(options.mobileCommandHandlers)); app.use(notFoundHandler); app.use(errorHandler); diff --git a/backend/command-api/src/contracts/commands/attendance.js b/backend/command-api/src/contracts/commands/attendance.js new file mode 100644 index 00000000..fb797943 --- /dev/null +++ b/backend/command-api/src/contracts/commands/attendance.js @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const attendanceCommandSchema = z.object({ + assignmentId: z.string().uuid(), + sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']), + sourceReference: z.string().max(255).optional(), + nfcTagUid: z.string().max(255).optional(), + deviceId: z.string().max(255).optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + accuracyMeters: z.number().int().nonnegative().optional(), + capturedAt: z.string().datetime().optional(), + rawPayload: z.record(z.any()).optional(), +}); diff --git a/backend/command-api/src/contracts/commands/favorite-staff.js b/backend/command-api/src/contracts/commands/favorite-staff.js new file mode 100644 index 00000000..f27c9a07 --- /dev/null +++ b/backend/command-api/src/contracts/commands/favorite-staff.js @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const favoriteStaffSchema = z.object({ + tenantId: z.string().uuid(), + businessId: z.string().uuid(), + staffId: z.string().uuid(), +}); diff --git a/backend/command-api/src/contracts/commands/mobile.js b/backend/command-api/src/contracts/commands/mobile.js new file mode 100644 index 00000000..e7f65551 --- /dev/null +++ b/backend/command-api/src/contracts/commands/mobile.js @@ -0,0 +1,360 @@ +import { z } from 'zod'; + +const timeSlotSchema = z.object({ + start: z.string().min(1).max(20), + end: z.string().min(1).max(20), +}); + +const preferredLocationSchema = z.object({ + label: z.string().min(1).max(160), + city: z.string().max(120).optional(), + state: z.string().max(80).optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + radiusMiles: z.number().nonnegative().optional(), +}); + +const hhmmSchema = z.string().regex(/^\d{2}:\d{2}$/, 'Time must use HH:MM format'); +const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must use YYYY-MM-DD format'); +const clockInModeSchema = z.enum(['NFC_REQUIRED', 'GEO_REQUIRED', 'EITHER']); +const pushProviderSchema = z.enum(['FCM', 'APNS', 'WEB_PUSH']); +const pushPlatformSchema = z.enum(['IOS', 'ANDROID', 'WEB']); + +const shiftPositionSchema = z.object({ + roleId: z.string().uuid().optional(), + roleCode: z.string().min(1).max(120).optional(), + roleName: z.string().min(1).max(160).optional(), + workerCount: z.number().int().positive().optional(), + workersNeeded: z.number().int().positive().optional(), + startTime: hhmmSchema, + endTime: hhmmSchema, + hourlyRateCents: z.number().int().nonnegative().optional(), + payRateCents: z.number().int().nonnegative().optional(), + billRateCents: z.number().int().nonnegative().optional(), + lunchBreakMinutes: z.number().int().nonnegative().optional(), + paidBreak: z.boolean().optional(), + instantBook: z.boolean().optional(), + metadata: z.record(z.any()).optional(), +}).superRefine((value, ctx) => { + if (!value.roleId && !value.roleCode && !value.roleName) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'roleId, roleCode, or roleName is required', + path: ['roleId'], + }); + } +}); + +const baseOrderCreateSchema = z.object({ + hubId: z.string().uuid(), + vendorId: z.string().uuid().optional(), + eventName: z.string().min(2).max(160), + timezone: z.string().min(1).max(80).optional(), + description: z.string().max(5000).optional(), + notes: z.string().max(5000).optional(), + serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(), + positions: z.array(shiftPositionSchema).min(1), + metadata: z.record(z.any()).optional(), +}); + +export const hubCreateSchema = z.object({ + name: z.string().min(1).max(160), + fullAddress: z.string().max(300).optional(), + placeId: z.string().max(255).optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + street: z.string().max(160).optional(), + city: z.string().max(120).optional(), + state: z.string().max(80).optional(), + country: z.string().max(80).optional(), + zipCode: z.string().max(40).optional(), + costCenterId: z.string().uuid().optional(), + geofenceRadiusMeters: z.number().int().positive().optional(), + nfcTagId: z.string().max(255).optional(), + clockInMode: clockInModeSchema.optional(), + allowClockInOverride: z.boolean().optional(), +}); + +export const hubUpdateSchema = hubCreateSchema.extend({ + hubId: z.string().uuid(), +}); + +export const hubDeleteSchema = z.object({ + hubId: z.string().uuid(), + reason: z.string().max(1000).optional(), +}); + +export const hubAssignNfcSchema = z.object({ + hubId: z.string().uuid(), + nfcTagId: z.string().min(1).max(255), +}); + +export const hubAssignManagerSchema = z.object({ + hubId: z.string().uuid(), + businessMembershipId: z.string().uuid().optional(), + managerUserId: z.string().min(1).optional(), +}).refine((value) => value.businessMembershipId || value.managerUserId, { + message: 'businessMembershipId or managerUserId is required', +}); + +export const invoiceApproveSchema = z.object({ + invoiceId: z.string().uuid(), +}); + +export const invoiceDisputeSchema = z.object({ + invoiceId: z.string().uuid(), + reason: z.string().min(3).max(2000), +}); + +export const coverageReviewSchema = z.object({ + staffId: z.string().uuid(), + assignmentId: z.string().uuid().optional(), + rating: z.number().int().min(1).max(5), + markAsFavorite: z.boolean().optional(), + issueFlags: z.array(z.string().min(1).max(80)).max(20).optional(), + feedback: z.string().max(5000).optional(), +}); + +export const cancelLateWorkerSchema = z.object({ + assignmentId: z.string().uuid(), + reason: z.string().max(1000).optional(), +}); + +export const clientOneTimeOrderSchema = baseOrderCreateSchema.extend({ + orderDate: isoDateSchema, +}); + +export const clientRecurringOrderSchema = baseOrderCreateSchema.extend({ + startDate: isoDateSchema, + endDate: isoDateSchema, + recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1), +}); + +export const clientPermanentOrderSchema = baseOrderCreateSchema.extend({ + startDate: isoDateSchema, + endDate: isoDateSchema.optional(), + daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(), + horizonDays: z.number().int().min(7).max(180).optional(), +}); + +export const clientOrderEditSchema = z.object({ + orderId: z.string().uuid(), + orderType: z.enum(['ONE_TIME', 'RECURRING', 'PERMANENT']).optional(), + hubId: z.string().uuid().optional(), + vendorId: z.string().uuid().optional(), + eventName: z.string().min(2).max(160).optional(), + orderDate: isoDateSchema.optional(), + startDate: isoDateSchema.optional(), + endDate: isoDateSchema.optional(), + recurrenceDays: z.array(z.number().int().min(0).max(6)).min(1).optional(), + daysOfWeek: z.array(z.number().int().min(0).max(6)).min(1).optional(), + timezone: z.string().min(1).max(80).optional(), + description: z.string().max(5000).optional(), + notes: z.string().max(5000).optional(), + serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(), + positions: z.array(shiftPositionSchema).min(1).optional(), + metadata: z.record(z.any()).optional(), +}).superRefine((value, ctx) => { + const keys = Object.keys(value).filter((key) => key !== 'orderId'); + if (keys.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'At least one field must be provided to create an edited order copy', + path: [], + }); + } +}); + +export const clientOrderCancelSchema = z.object({ + orderId: z.string().uuid(), + reason: z.string().max(1000).optional(), + metadata: z.record(z.any()).optional(), +}); + +export const availabilityDayUpdateSchema = z.object({ + dayOfWeek: z.number().int().min(0).max(6), + availabilityStatus: z.enum(['AVAILABLE', 'UNAVAILABLE', 'PARTIAL']), + slots: z.array(timeSlotSchema).max(8).optional(), + metadata: z.record(z.any()).optional(), +}); + +export const availabilityQuickSetSchema = z.object({ + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + quickSetType: z.enum(['all', 'weekdays', 'weekends', 'clear']), + slots: z.array(timeSlotSchema).max(8).optional(), +}); + +export const shiftApplySchema = z.object({ + shiftId: z.string().uuid(), + roleId: z.string().uuid().optional(), + instantBook: z.boolean().optional(), +}); + +export const shiftDecisionSchema = z.object({ + shiftId: z.string().uuid(), + reason: z.string().max(1000).optional(), +}); + +export const staffClockInSchema = z.object({ + assignmentId: z.string().uuid().optional(), + shiftId: z.string().uuid().optional(), + sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(), + sourceReference: z.string().max(255).optional(), + nfcTagId: z.string().max(255).optional(), + deviceId: z.string().max(255).optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + accuracyMeters: z.number().int().nonnegative().optional(), + capturedAt: z.string().datetime().optional(), + proofNonce: z.string().min(8).max(255).optional(), + proofTimestamp: z.string().datetime().optional(), + attestationProvider: z.enum(['PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK']).optional(), + attestationToken: z.string().min(16).max(20000).optional(), + isMockLocation: z.boolean().optional(), + notes: z.string().max(2000).optional(), + overrideReason: z.string().max(2000).optional(), + rawPayload: z.record(z.any()).optional(), +}).refine((value) => value.assignmentId || value.shiftId, { + message: 'assignmentId or shiftId is required', +}); + +export const staffClockOutSchema = z.object({ + assignmentId: z.string().uuid().optional(), + shiftId: z.string().uuid().optional(), + applicationId: z.string().uuid().optional(), + sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).optional(), + sourceReference: z.string().max(255).optional(), + nfcTagId: z.string().max(255).optional(), + deviceId: z.string().max(255).optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + accuracyMeters: z.number().int().nonnegative().optional(), + capturedAt: z.string().datetime().optional(), + proofNonce: z.string().min(8).max(255).optional(), + proofTimestamp: z.string().datetime().optional(), + attestationProvider: z.enum(['PLAY_INTEGRITY', 'APP_ATTEST', 'DEVICE_CHECK']).optional(), + attestationToken: z.string().min(16).max(20000).optional(), + isMockLocation: z.boolean().optional(), + notes: z.string().max(2000).optional(), + overrideReason: z.string().max(2000).optional(), + breakMinutes: z.number().int().nonnegative().optional(), + rawPayload: z.record(z.any()).optional(), +}).refine((value) => value.assignmentId || value.shiftId || value.applicationId, { + message: 'assignmentId, shiftId, or applicationId is required', +}); + +const locationPointSchema = z.object({ + capturedAt: z.string().datetime(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + accuracyMeters: z.number().int().nonnegative().optional(), + speedMps: z.number().nonnegative().optional(), + isMocked: z.boolean().optional(), + metadata: z.record(z.any()).optional(), +}); + +export const staffLocationBatchSchema = z.object({ + assignmentId: z.string().uuid().optional(), + shiftId: z.string().uuid().optional(), + sourceType: z.enum(['NFC', 'GEO', 'QR', 'MANUAL', 'SYSTEM']).default('GEO'), + deviceId: z.string().max(255).optional(), + points: z.array(locationPointSchema).min(1).max(96), + metadata: z.record(z.any()).optional(), +}).refine((value) => value.assignmentId || value.shiftId, { + message: 'assignmentId or shiftId is required', +}); + +export const pushTokenRegisterSchema = z.object({ + provider: pushProviderSchema.default('FCM'), + platform: pushPlatformSchema, + pushToken: z.string().min(16).max(4096), + deviceId: z.string().max(255).optional(), + appVersion: z.string().max(80).optional(), + appBuild: z.string().max(80).optional(), + locale: z.string().max(32).optional(), + timezone: z.string().max(64).optional(), + notificationsEnabled: z.boolean().optional(), + metadata: z.record(z.any()).optional(), +}); + +export const pushTokenDeleteSchema = z.object({ + tokenId: z.string().uuid().optional(), + pushToken: z.string().min(16).max(4096).optional(), + reason: z.string().max(255).optional(), +}).refine((value) => value.tokenId || value.pushToken, { + message: 'tokenId or pushToken is required', +}); + +export const staffProfileSetupSchema = z.object({ + fullName: z.string().min(2).max(160), + bio: z.string().max(5000).optional(), + email: z.string().email().optional(), + phoneNumber: z.string().min(6).max(40), + preferredLocations: z.array(preferredLocationSchema).max(20).optional(), + maxDistanceMiles: z.number().nonnegative().max(500).optional(), + industries: z.array(z.string().min(1).max(80)).max(30).optional(), + skills: z.array(z.string().min(1).max(80)).max(50).optional(), + primaryRole: z.string().max(120).optional(), + tenantId: z.string().uuid().optional(), + vendorId: z.string().uuid().optional(), +}); + +export const personalInfoUpdateSchema = z.object({ + firstName: z.string().min(1).max(80).optional(), + lastName: z.string().min(1).max(80).optional(), + bio: z.string().max(5000).optional(), + preferredLocations: z.array(preferredLocationSchema).max(20).optional(), + maxDistanceMiles: z.number().nonnegative().max(500).optional(), + email: z.string().email().optional(), + phone: z.string().min(6).max(40).optional(), + displayName: z.string().min(2).max(160).optional(), +}); + +export const profileExperienceSchema = z.object({ + industries: z.array(z.string().min(1).max(80)).max(30).optional(), + skills: z.array(z.string().min(1).max(80)).max(50).optional(), + primaryRole: z.string().max(120).optional(), +}); + +export const preferredLocationsUpdateSchema = z.object({ + preferredLocations: z.array(preferredLocationSchema).max(20), + maxDistanceMiles: z.number().nonnegative().max(500).optional(), +}); + +export const emergencyContactCreateSchema = z.object({ + fullName: z.string().min(2).max(160), + phone: z.string().min(6).max(40), + relationshipType: z.string().min(1).max(120), + isPrimary: z.boolean().optional(), + metadata: z.record(z.any()).optional(), +}); + +export const emergencyContactUpdateSchema = emergencyContactCreateSchema.partial().extend({ + contactId: z.string().uuid(), +}); + +const taxFormFieldsSchema = z.record(z.any()); + +export const taxFormDraftSchema = z.object({ + formType: z.enum(['I9', 'W4']), + fields: taxFormFieldsSchema, +}); + +export const taxFormSubmitSchema = z.object({ + formType: z.enum(['I9', 'W4']), + fields: taxFormFieldsSchema, +}); + +export const bankAccountCreateSchema = z.object({ + bankName: z.string().min(2).max(160), + accountNumber: z.string().min(4).max(34), + routingNumber: z.string().min(4).max(20), + accountType: z.string() + .transform((value) => value.trim().toUpperCase()) + .pipe(z.enum(['CHECKING', 'SAVINGS'])), +}); + +export const privacyUpdateSchema = z.object({ + profileVisible: z.boolean(), +}); diff --git a/backend/command-api/src/contracts/commands/order-cancel.js b/backend/command-api/src/contracts/commands/order-cancel.js new file mode 100644 index 00000000..1b1ea3b6 --- /dev/null +++ b/backend/command-api/src/contracts/commands/order-cancel.js @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const orderCancelSchema = z.object({ + orderId: z.string().uuid(), + tenantId: z.string().uuid(), + reason: z.string().max(1000).optional(), + metadata: z.record(z.any()).optional(), +}); diff --git a/backend/command-api/src/contracts/commands/order-create.js b/backend/command-api/src/contracts/commands/order-create.js new file mode 100644 index 00000000..5dc672c7 --- /dev/null +++ b/backend/command-api/src/contracts/commands/order-create.js @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +const roleSchema = z.object({ + roleId: z.string().uuid().optional(), + roleCode: z.string().min(1).max(100), + roleName: z.string().min(1).max(120), + workersNeeded: z.number().int().positive(), + payRateCents: z.number().int().nonnegative().optional(), + billRateCents: z.number().int().nonnegative().optional(), + metadata: z.record(z.any()).optional(), +}); + +const shiftSchema = z.object({ + shiftCode: z.string().min(1).max(80), + title: z.string().min(1).max(160), + status: z.enum([ + 'DRAFT', + 'OPEN', + 'PENDING_CONFIRMATION', + 'ASSIGNED', + 'ACTIVE', + 'COMPLETED', + 'CANCELLED', + ]).optional(), + startsAt: z.string().datetime(), + endsAt: z.string().datetime(), + timezone: z.string().min(1).max(80).optional(), + clockPointId: z.string().uuid().optional(), + locationName: z.string().max(160).optional(), + locationAddress: z.string().max(300).optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + geofenceRadiusMeters: z.number().int().positive().optional(), + requiredWorkers: z.number().int().positive(), + notes: z.string().max(5000).optional(), + metadata: z.record(z.any()).optional(), + roles: z.array(roleSchema).min(1), +}); + +export const orderCreateSchema = z.object({ + tenantId: z.string().uuid(), + businessId: z.string().uuid(), + vendorId: z.string().uuid().optional(), + orderNumber: z.string().min(1).max(80), + title: z.string().min(1).max(160), + description: z.string().max(5000).optional(), + status: z.enum(['DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED', 'CANCELLED']).optional(), + serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(), + startsAt: z.string().datetime().optional(), + endsAt: z.string().datetime().optional(), + locationName: z.string().max(160).optional(), + locationAddress: z.string().max(300).optional(), + latitude: z.number().min(-90).max(90).optional(), + longitude: z.number().min(-180).max(180).optional(), + notes: z.string().max(5000).optional(), + metadata: z.record(z.any()).optional(), + shifts: z.array(shiftSchema).min(1), +}); diff --git a/backend/command-api/src/contracts/commands/order-update.js b/backend/command-api/src/contracts/commands/order-update.js new file mode 100644 index 00000000..1eb039f1 --- /dev/null +++ b/backend/command-api/src/contracts/commands/order-update.js @@ -0,0 +1,35 @@ +import { z } from 'zod'; + +const nullableString = (max) => z.union([z.string().max(max), z.null()]); +const nullableDateTime = z.union([z.string().datetime(), z.null()]); +const nullableUuid = z.union([z.string().uuid(), z.null()]); + +const orderUpdateShape = { + orderId: z.string().uuid(), + tenantId: z.string().uuid(), + vendorId: nullableUuid.optional(), + title: nullableString(160).optional(), + description: nullableString(5000).optional(), + status: z.enum(['DRAFT', 'OPEN', 'FILLED', 'ACTIVE', 'COMPLETED']).optional(), + serviceType: z.enum(['EVENT', 'CATERING', 'HOTEL', 'RESTAURANT', 'OTHER']).optional(), + startsAt: nullableDateTime.optional(), + endsAt: nullableDateTime.optional(), + locationName: nullableString(160).optional(), + locationAddress: nullableString(300).optional(), + latitude: z.union([z.number().min(-90).max(90), z.null()]).optional(), + longitude: z.union([z.number().min(-180).max(180), z.null()]).optional(), + notes: nullableString(5000).optional(), + metadata: z.record(z.any()).optional(), +}; + +export const orderUpdateSchema = z.object(orderUpdateShape).superRefine((value, ctx) => { + const mutableKeys = Object.keys(orderUpdateShape).filter((key) => !['orderId', 'tenantId'].includes(key)); + const hasMutableField = mutableKeys.some((key) => Object.prototype.hasOwnProperty.call(value, key)); + if (!hasMutableField) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'At least one mutable order field must be provided', + path: [], + }); + } +}); diff --git a/backend/command-api/src/contracts/commands/shift-accept.js b/backend/command-api/src/contracts/commands/shift-accept.js new file mode 100644 index 00000000..19d29d3c --- /dev/null +++ b/backend/command-api/src/contracts/commands/shift-accept.js @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const shiftAcceptSchema = z.object({ + shiftId: z.string().uuid().optional(), + shiftRoleId: z.string().uuid(), + workforceId: z.string().uuid(), + metadata: z.record(z.any()).optional(), +}); diff --git a/backend/command-api/src/contracts/commands/shift-assign-staff.js b/backend/command-api/src/contracts/commands/shift-assign-staff.js new file mode 100644 index 00000000..2df3af81 --- /dev/null +++ b/backend/command-api/src/contracts/commands/shift-assign-staff.js @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +export const shiftAssignStaffSchema = z.object({ + shiftId: z.string().uuid(), + tenantId: z.string().uuid(), + shiftRoleId: z.string().uuid(), + workforceId: z.string().uuid(), + applicationId: z.string().uuid().optional(), + metadata: z.record(z.any()).optional(), +}); diff --git a/backend/command-api/src/contracts/commands/shift-status-change.js b/backend/command-api/src/contracts/commands/shift-status-change.js new file mode 100644 index 00000000..296c7877 --- /dev/null +++ b/backend/command-api/src/contracts/commands/shift-status-change.js @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const shiftStatusChangeSchema = z.object({ + shiftId: z.string().uuid(), + tenantId: z.string().uuid(), + status: z.enum([ + 'DRAFT', + 'OPEN', + 'PENDING_CONFIRMATION', + 'ASSIGNED', + 'ACTIVE', + 'COMPLETED', + 'CANCELLED', + ]), + reason: z.string().max(1000).optional(), + metadata: z.record(z.any()).optional(), +}); diff --git a/backend/command-api/src/contracts/commands/staff-review.js b/backend/command-api/src/contracts/commands/staff-review.js new file mode 100644 index 00000000..d53e1ca6 --- /dev/null +++ b/backend/command-api/src/contracts/commands/staff-review.js @@ -0,0 +1,11 @@ +import { z } from 'zod'; + +export const staffReviewSchema = z.object({ + tenantId: z.string().uuid(), + businessId: z.string().uuid(), + staffId: z.string().uuid(), + assignmentId: z.string().uuid(), + rating: z.number().int().min(1).max(5), + reviewText: z.string().max(5000).optional(), + tags: z.array(z.string().min(1).max(80)).max(20).optional(), +}); diff --git a/backend/command-api/src/routes/commands.js b/backend/command-api/src/routes/commands.js index a16d23da..5fc82c10 100644 --- a/backend/command-api/src/routes/commands.js +++ b/backend/command-api/src/routes/commands.js @@ -3,10 +3,45 @@ import { AppError } from '../lib/errors.js'; import { requireAuth, requirePolicy } from '../middleware/auth.js'; import { requireIdempotencyKey } from '../middleware/idempotency.js'; import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js'; -import { commandBaseSchema } from '../contracts/commands/command-base.js'; +import { + addFavoriteStaff, + clockIn, + clockOut, + createOrder, + createStaffReview, + updateOrder, + cancelOrder, + changeShiftStatus, + assignStaffToShift, + removeFavoriteStaff, + acceptShift, +} from '../services/command-service.js'; +import { attendanceCommandSchema } from '../contracts/commands/attendance.js'; +import { favoriteStaffSchema } from '../contracts/commands/favorite-staff.js'; +import { orderCancelSchema } from '../contracts/commands/order-cancel.js'; +import { orderCreateSchema } from '../contracts/commands/order-create.js'; +import { orderUpdateSchema } from '../contracts/commands/order-update.js'; +import { shiftAssignStaffSchema } from '../contracts/commands/shift-assign-staff.js'; +import { shiftAcceptSchema } from '../contracts/commands/shift-accept.js'; +import { shiftStatusChangeSchema } from '../contracts/commands/shift-status-change.js'; +import { staffReviewSchema } from '../contracts/commands/staff-review.js'; -function parseBody(body) { - const parsed = commandBaseSchema.safeParse(body || {}); +const defaultHandlers = { + addFavoriteStaff, + assignStaffToShift, + cancelOrder, + changeShiftStatus, + clockIn, + clockOut, + createOrder, + createStaffReview, + removeFavoriteStaff, + acceptShift, + updateOrder, +}; + +function parseBody(schema, body) { + const parsed = schema.safeParse(body || {}); if (!parsed.success) { throw new AppError('VALIDATION_ERROR', 'Invalid command payload', 400, { issues: parsed.error.issues, @@ -15,50 +50,37 @@ function parseBody(body) { return parsed.data; } -function createCommandResponse(route, requestId, idempotencyKey) { - return { - accepted: true, +async function runIdempotentCommand(req, res, work) { + const route = `${req.baseUrl}${req.route.path}`; + const compositeKey = buildIdempotencyKey({ + userId: req.actor.uid, route, - commandId: `${route}:${Date.now()}`, - idempotencyKey, - requestId, + idempotencyKey: req.idempotencyKey, + }); + + const existing = await readIdempotentResult(compositeKey); + if (existing) { + return res.status(existing.statusCode).json(existing.payload); + } + + const payload = await work(); + const responsePayload = { + ...payload, + idempotencyKey: req.idempotencyKey, + requestId: req.requestId, }; + const persisted = await writeIdempotentResult({ + compositeKey, + userId: req.actor.uid, + route, + idempotencyKey: req.idempotencyKey, + payload: responsePayload, + statusCode: 200, + }); + return res.status(persisted.statusCode).json(persisted.payload); } -function buildCommandHandler(policyAction, policyResource) { - return async (req, res, next) => { - try { - parseBody(req.body); - - const route = `${req.baseUrl}${req.route.path}`; - const compositeKey = buildIdempotencyKey({ - userId: req.actor.uid, - route, - idempotencyKey: req.idempotencyKey, - }); - - const existing = await readIdempotentResult(compositeKey); - if (existing) { - return res.status(existing.statusCode).json(existing.payload); - } - - const payload = createCommandResponse(route, req.requestId, req.idempotencyKey); - const persisted = await writeIdempotentResult({ - compositeKey, - userId: req.actor.uid, - route, - idempotencyKey: req.idempotencyKey, - payload, - statusCode: 200, - }); - return res.status(persisted.statusCode).json(persisted.payload); - } catch (error) { - return next(error); - } - }; -} - -export function createCommandsRouter() { +export function createCommandsRouter(handlers = defaultHandlers) { const router = Router(); router.post( @@ -66,7 +88,14 @@ export function createCommandsRouter() { requireAuth, requireIdempotencyKey, requirePolicy('orders.create', 'order'), - buildCommandHandler('orders.create', 'order') + async (req, res, next) => { + try { + const payload = parseBody(orderCreateSchema, req.body); + return await runIdempotentCommand(req, res, () => handlers.createOrder(req.actor, payload)); + } catch (error) { + return next(error); + } + } ); router.post( @@ -74,7 +103,17 @@ export function createCommandsRouter() { requireAuth, requireIdempotencyKey, requirePolicy('orders.update', 'order'), - buildCommandHandler('orders.update', 'order') + async (req, res, next) => { + try { + const payload = parseBody(orderUpdateSchema, { + ...req.body, + orderId: req.params.orderId, + }); + return await runIdempotentCommand(req, res, () => handlers.updateOrder(req.actor, payload)); + } catch (error) { + return next(error); + } + } ); router.post( @@ -82,7 +121,17 @@ export function createCommandsRouter() { requireAuth, requireIdempotencyKey, requirePolicy('orders.cancel', 'order'), - buildCommandHandler('orders.cancel', 'order') + async (req, res, next) => { + try { + const payload = parseBody(orderCancelSchema, { + ...req.body, + orderId: req.params.orderId, + }); + return await runIdempotentCommand(req, res, () => handlers.cancelOrder(req.actor, payload)); + } catch (error) { + return next(error); + } + } ); router.post( @@ -90,7 +139,17 @@ export function createCommandsRouter() { requireAuth, requireIdempotencyKey, requirePolicy('shifts.change-status', 'shift'), - buildCommandHandler('shifts.change-status', 'shift') + async (req, res, next) => { + try { + const payload = parseBody(shiftStatusChangeSchema, { + ...req.body, + shiftId: req.params.shiftId, + }); + return await runIdempotentCommand(req, res, () => handlers.changeShiftStatus(req.actor, payload)); + } catch (error) { + return next(error); + } + } ); router.post( @@ -98,7 +157,17 @@ export function createCommandsRouter() { requireAuth, requireIdempotencyKey, requirePolicy('shifts.assign-staff', 'shift'), - buildCommandHandler('shifts.assign-staff', 'shift') + async (req, res, next) => { + try { + const payload = parseBody(shiftAssignStaffSchema, { + ...req.body, + shiftId: req.params.shiftId, + }); + return await runIdempotentCommand(req, res, () => handlers.assignStaffToShift(req.actor, payload)); + } catch (error) { + return next(error); + } + } ); router.post( @@ -106,7 +175,102 @@ export function createCommandsRouter() { requireAuth, requireIdempotencyKey, requirePolicy('shifts.accept', 'shift'), - buildCommandHandler('shifts.accept', 'shift') + async (req, res, next) => { + try { + const payload = parseBody(shiftAcceptSchema, { + ...req.body, + shiftId: req.params.shiftId, + }); + return await runIdempotentCommand(req, res, () => handlers.acceptShift(req.actor, payload)); + } catch (error) { + return next(error); + } + } + ); + + router.post( + '/attendance/clock-in', + requireAuth, + requireIdempotencyKey, + requirePolicy('attendance.clock-in', 'attendance'), + async (req, res, next) => { + try { + const payload = parseBody(attendanceCommandSchema, req.body); + return await runIdempotentCommand(req, res, () => handlers.clockIn(req.actor, payload)); + } catch (error) { + return next(error); + } + } + ); + + router.post( + '/attendance/clock-out', + requireAuth, + requireIdempotencyKey, + requirePolicy('attendance.clock-out', 'attendance'), + async (req, res, next) => { + try { + const payload = parseBody(attendanceCommandSchema, req.body); + return await runIdempotentCommand(req, res, () => handlers.clockOut(req.actor, payload)); + } catch (error) { + return next(error); + } + } + ); + + router.post( + '/businesses/:businessId/favorite-staff', + requireAuth, + requireIdempotencyKey, + requirePolicy('business.favorite-staff', 'staff'), + async (req, res, next) => { + try { + const payload = parseBody(favoriteStaffSchema, { + ...req.body, + businessId: req.params.businessId, + }); + return await runIdempotentCommand(req, res, () => handlers.addFavoriteStaff(req.actor, payload)); + } catch (error) { + return next(error); + } + } + ); + + router.delete( + '/businesses/:businessId/favorite-staff/:staffId', + requireAuth, + requireIdempotencyKey, + requirePolicy('business.unfavorite-staff', 'staff'), + async (req, res, next) => { + try { + const payload = parseBody(favoriteStaffSchema, { + ...req.body, + businessId: req.params.businessId, + staffId: req.params.staffId, + }); + return await runIdempotentCommand(req, res, () => handlers.removeFavoriteStaff(req.actor, payload)); + } catch (error) { + return next(error); + } + } + ); + + router.post( + '/assignments/:assignmentId/reviews', + requireAuth, + requireIdempotencyKey, + requirePolicy('assignments.review-staff', 'assignment'), + async (req, res, next) => { + try { + const payload = parseBody(staffReviewSchema, { + ...req.body, + assignmentId: req.params.assignmentId, + }); + return await runIdempotentCommand(req, res, () => handlers.createStaffReview(req.actor, payload)); + } catch (error) { + return next(error); + } + } ); return router; diff --git a/backend/command-api/src/routes/health.js b/backend/command-api/src/routes/health.js index 90cd5690..4dbbdca0 100644 --- a/backend/command-api/src/routes/health.js +++ b/backend/command-api/src/routes/health.js @@ -1,4 +1,5 @@ import { Router } from 'express'; +import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js'; export const healthRouter = Router(); @@ -13,3 +14,32 @@ function healthHandler(req, res) { healthRouter.get('/health', healthHandler); healthRouter.get('/healthz', healthHandler); + +healthRouter.get('/readyz', async (req, res) => { + if (!isDatabaseConfigured()) { + return res.status(503).json({ + ok: false, + service: 'krow-command-api', + status: 'DATABASE_NOT_CONFIGURED', + requestId: req.requestId, + }); + } + + try { + const ok = await checkDatabaseHealth(); + return res.status(ok ? 200 : 503).json({ + ok, + service: 'krow-command-api', + status: ok ? 'READY' : 'DATABASE_UNAVAILABLE', + requestId: req.requestId, + }); + } catch (error) { + return res.status(503).json({ + ok: false, + service: 'krow-command-api', + status: 'DATABASE_UNAVAILABLE', + details: { message: error.message }, + requestId: req.requestId, + }); + } +}); diff --git a/backend/command-api/src/routes/mobile.js b/backend/command-api/src/routes/mobile.js new file mode 100644 index 00000000..be97e3c0 --- /dev/null +++ b/backend/command-api/src/routes/mobile.js @@ -0,0 +1,472 @@ +import { Router } from 'express'; +import { AppError } from '../lib/errors.js'; +import { requireAuth, requirePolicy } from '../middleware/auth.js'; +import { requireIdempotencyKey } from '../middleware/idempotency.js'; +import { buildIdempotencyKey, readIdempotentResult, writeIdempotentResult } from '../services/idempotency-store.js'; +import { + addStaffBankAccount, + approveInvoice, + applyForShift, + assignHubManager, + assignHubNfc, + cancelLateWorker, + cancelClientOrder, + createEmergencyContact, + createClientOneTimeOrder, + createClientPermanentOrder, + createClientRecurringOrder, + createEditedOrderCopy, + createHub, + declinePendingShift, + disputeInvoice, + quickSetStaffAvailability, + rateWorkerFromCoverage, + registerClientPushToken, + registerStaffPushToken, + requestShiftSwap, + saveTaxFormDraft, + setupStaffProfile, + staffClockIn, + staffClockOut, + submitLocationStreamBatch, + submitTaxForm, + unregisterClientPushToken, + unregisterStaffPushToken, + updateEmergencyContact, + updateHub, + updatePersonalInfo, + updatePreferredLocations, + updatePrivacyVisibility, + updateProfileExperience, + updateStaffAvailabilityDay, + deleteHub, + acceptPendingShift, +} from '../services/mobile-command-service.js'; +import { + availabilityDayUpdateSchema, + availabilityQuickSetSchema, + bankAccountCreateSchema, + cancelLateWorkerSchema, + clientOneTimeOrderSchema, + clientOrderCancelSchema, + clientOrderEditSchema, + clientPermanentOrderSchema, + clientRecurringOrderSchema, + coverageReviewSchema, + emergencyContactCreateSchema, + emergencyContactUpdateSchema, + hubAssignManagerSchema, + hubAssignNfcSchema, + hubCreateSchema, + hubDeleteSchema, + hubUpdateSchema, + invoiceApproveSchema, + invoiceDisputeSchema, + personalInfoUpdateSchema, + preferredLocationsUpdateSchema, + privacyUpdateSchema, + profileExperienceSchema, + pushTokenDeleteSchema, + pushTokenRegisterSchema, + shiftApplySchema, + shiftDecisionSchema, + staffClockInSchema, + staffClockOutSchema, + staffLocationBatchSchema, + staffProfileSetupSchema, + taxFormDraftSchema, + taxFormSubmitSchema, +} from '../contracts/commands/mobile.js'; + +const defaultHandlers = { + acceptPendingShift, + addStaffBankAccount, + approveInvoice, + applyForShift, + assignHubManager, + assignHubNfc, + cancelLateWorker, + cancelClientOrder, + createEmergencyContact, + createClientOneTimeOrder, + createClientPermanentOrder, + createClientRecurringOrder, + createEditedOrderCopy, + createHub, + declinePendingShift, + disputeInvoice, + quickSetStaffAvailability, + rateWorkerFromCoverage, + registerClientPushToken, + registerStaffPushToken, + requestShiftSwap, + saveTaxFormDraft, + setupStaffProfile, + staffClockIn, + staffClockOut, + submitLocationStreamBatch, + submitTaxForm, + unregisterClientPushToken, + unregisterStaffPushToken, + updateEmergencyContact, + updateHub, + updatePersonalInfo, + updatePreferredLocations, + updatePrivacyVisibility, + updateProfileExperience, + updateStaffAvailabilityDay, + deleteHub, +}; + +function parseBody(schema, body) { + const parsed = schema.safeParse(body || {}); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid request payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +async function runIdempotentCommand(req, res, work) { + const route = `${req.baseUrl}${req.route.path}`; + const compositeKey = buildIdempotencyKey({ + userId: req.actor.uid, + route, + idempotencyKey: req.idempotencyKey, + }); + + const existing = await readIdempotentResult(compositeKey); + if (existing) { + return res.status(existing.statusCode).json(existing.payload); + } + + const payload = await work(); + const responsePayload = { + ...payload, + idempotencyKey: req.idempotencyKey, + requestId: req.requestId, + }; + const persisted = await writeIdempotentResult({ + compositeKey, + userId: req.actor.uid, + route, + idempotencyKey: req.idempotencyKey, + payload: responsePayload, + statusCode: 200, + }); + return res.status(persisted.statusCode).json(persisted.payload); +} + +function mobileCommand(route, { schema, policyAction, resource, handler, paramShape }) { + return [ + route, + requireAuth, + requireIdempotencyKey, + requirePolicy(policyAction, resource), + async (req, res, next) => { + try { + const body = typeof paramShape === 'function' + ? paramShape(req) + : req.body; + const payload = parseBody(schema, body); + return await runIdempotentCommand(req, res, () => handler(req.actor, payload)); + } catch (error) { + return next(error); + } + }, + ]; +} + +export function createMobileCommandsRouter(handlers = defaultHandlers) { + const router = Router(); + + router.post(...mobileCommand('/client/orders/one-time', { + schema: clientOneTimeOrderSchema, + policyAction: 'orders.create', + resource: 'order', + handler: handlers.createClientOneTimeOrder, + })); + + router.post(...mobileCommand('/client/orders/recurring', { + schema: clientRecurringOrderSchema, + policyAction: 'orders.create', + resource: 'order', + handler: handlers.createClientRecurringOrder, + })); + + router.post(...mobileCommand('/client/orders/permanent', { + schema: clientPermanentOrderSchema, + policyAction: 'orders.create', + resource: 'order', + handler: handlers.createClientPermanentOrder, + })); + + router.post(...mobileCommand('/client/orders/:orderId/edit', { + schema: clientOrderEditSchema, + policyAction: 'orders.update', + resource: 'order', + handler: handlers.createEditedOrderCopy, + paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }), + })); + + router.post(...mobileCommand('/client/orders/:orderId/cancel', { + schema: clientOrderCancelSchema, + policyAction: 'orders.cancel', + resource: 'order', + handler: handlers.cancelClientOrder, + paramShape: (req) => ({ ...req.body, orderId: req.params.orderId }), + })); + + router.post(...mobileCommand('/client/hubs', { + schema: hubCreateSchema, + policyAction: 'client.hubs.create', + resource: 'hub', + handler: handlers.createHub, + })); + + router.put(...mobileCommand('/client/hubs/:hubId', { + schema: hubUpdateSchema, + policyAction: 'client.hubs.update', + resource: 'hub', + handler: handlers.updateHub, + paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), + })); + + router.delete(...mobileCommand('/client/hubs/:hubId', { + schema: hubDeleteSchema, + policyAction: 'client.hubs.delete', + resource: 'hub', + handler: handlers.deleteHub, + paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), + })); + + router.post(...mobileCommand('/client/hubs/:hubId/assign-nfc', { + schema: hubAssignNfcSchema, + policyAction: 'client.hubs.update', + resource: 'hub', + handler: handlers.assignHubNfc, + paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), + })); + + router.post(...mobileCommand('/client/hubs/:hubId/managers', { + schema: hubAssignManagerSchema, + policyAction: 'client.hubs.update', + resource: 'hub', + handler: handlers.assignHubManager, + paramShape: (req) => ({ ...req.body, hubId: req.params.hubId }), + })); + + router.post(...mobileCommand('/client/billing/invoices/:invoiceId/approve', { + schema: invoiceApproveSchema, + policyAction: 'client.billing.write', + resource: 'invoice', + handler: handlers.approveInvoice, + paramShape: (req) => ({ invoiceId: req.params.invoiceId }), + })); + + router.post(...mobileCommand('/client/billing/invoices/:invoiceId/dispute', { + schema: invoiceDisputeSchema, + policyAction: 'client.billing.write', + resource: 'invoice', + handler: handlers.disputeInvoice, + paramShape: (req) => ({ ...req.body, invoiceId: req.params.invoiceId }), + })); + + router.post(...mobileCommand('/client/coverage/reviews', { + schema: coverageReviewSchema, + policyAction: 'client.coverage.write', + resource: 'staff_review', + handler: handlers.rateWorkerFromCoverage, + })); + + router.post(...mobileCommand('/client/coverage/late-workers/:assignmentId/cancel', { + schema: cancelLateWorkerSchema, + policyAction: 'client.coverage.write', + resource: 'assignment', + handler: handlers.cancelLateWorker, + paramShape: (req) => ({ ...req.body, assignmentId: req.params.assignmentId }), + })); + + router.post(...mobileCommand('/staff/profile/setup', { + schema: staffProfileSetupSchema, + policyAction: 'staff.profile.write', + resource: 'staff', + handler: handlers.setupStaffProfile, + })); + + router.post(...mobileCommand('/client/devices/push-tokens', { + schema: pushTokenRegisterSchema, + policyAction: 'notifications.device.write', + resource: 'device_push_token', + handler: handlers.registerClientPushToken, + })); + + router.delete(...mobileCommand('/client/devices/push-tokens', { + schema: pushTokenDeleteSchema, + policyAction: 'notifications.device.write', + resource: 'device_push_token', + handler: handlers.unregisterClientPushToken, + paramShape: (req) => ({ + ...req.body, + tokenId: req.body?.tokenId || req.query.tokenId, + pushToken: req.body?.pushToken || req.query.pushToken, + reason: req.body?.reason || req.query.reason, + }), + })); + + router.post(...mobileCommand('/staff/clock-in', { + schema: staffClockInSchema, + policyAction: 'attendance.clock-in', + resource: 'attendance', + handler: handlers.staffClockIn, + })); + + router.post(...mobileCommand('/staff/clock-out', { + schema: staffClockOutSchema, + policyAction: 'attendance.clock-out', + resource: 'attendance', + handler: handlers.staffClockOut, + })); + + router.post(...mobileCommand('/staff/location-streams', { + schema: staffLocationBatchSchema, + policyAction: 'attendance.location-stream.write', + resource: 'attendance', + handler: handlers.submitLocationStreamBatch, + })); + + router.post(...mobileCommand('/staff/devices/push-tokens', { + schema: pushTokenRegisterSchema, + policyAction: 'notifications.device.write', + resource: 'device_push_token', + handler: handlers.registerStaffPushToken, + })); + + router.delete(...mobileCommand('/staff/devices/push-tokens', { + schema: pushTokenDeleteSchema, + policyAction: 'notifications.device.write', + resource: 'device_push_token', + handler: handlers.unregisterStaffPushToken, + paramShape: (req) => ({ + ...req.body, + tokenId: req.body?.tokenId || req.query.tokenId, + pushToken: req.body?.pushToken || req.query.pushToken, + reason: req.body?.reason || req.query.reason, + }), + })); + + router.put(...mobileCommand('/staff/availability', { + schema: availabilityDayUpdateSchema, + policyAction: 'staff.availability.write', + resource: 'staff', + handler: handlers.updateStaffAvailabilityDay, + })); + + router.post(...mobileCommand('/staff/availability/quick-set', { + schema: availabilityQuickSetSchema, + policyAction: 'staff.availability.write', + resource: 'staff', + handler: handlers.quickSetStaffAvailability, + })); + + router.post(...mobileCommand('/staff/shifts/:shiftId/apply', { + schema: shiftApplySchema, + policyAction: 'staff.shifts.apply', + resource: 'shift', + handler: handlers.applyForShift, + paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), + })); + + router.post(...mobileCommand('/staff/shifts/:shiftId/accept', { + schema: shiftDecisionSchema, + policyAction: 'staff.shifts.accept', + resource: 'shift', + handler: handlers.acceptPendingShift, + paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), + })); + + router.post(...mobileCommand('/staff/shifts/:shiftId/decline', { + schema: shiftDecisionSchema, + policyAction: 'staff.shifts.decline', + resource: 'shift', + handler: handlers.declinePendingShift, + paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), + })); + + router.post(...mobileCommand('/staff/shifts/:shiftId/request-swap', { + schema: shiftDecisionSchema, + policyAction: 'staff.shifts.swap', + resource: 'shift', + handler: handlers.requestShiftSwap, + paramShape: (req) => ({ ...req.body, shiftId: req.params.shiftId }), + })); + + router.put(...mobileCommand('/staff/profile/personal-info', { + schema: personalInfoUpdateSchema, + policyAction: 'staff.profile.write', + resource: 'staff', + handler: handlers.updatePersonalInfo, + })); + + router.put(...mobileCommand('/staff/profile/experience', { + schema: profileExperienceSchema, + policyAction: 'staff.profile.write', + resource: 'staff', + handler: handlers.updateProfileExperience, + })); + + router.put(...mobileCommand('/staff/profile/locations', { + schema: preferredLocationsUpdateSchema, + policyAction: 'staff.profile.write', + resource: 'staff', + handler: handlers.updatePreferredLocations, + })); + + router.post(...mobileCommand('/staff/profile/emergency-contacts', { + schema: emergencyContactCreateSchema, + policyAction: 'staff.profile.write', + resource: 'staff', + handler: handlers.createEmergencyContact, + })); + + router.put(...mobileCommand('/staff/profile/emergency-contacts/:contactId', { + schema: emergencyContactUpdateSchema, + policyAction: 'staff.profile.write', + resource: 'staff', + handler: handlers.updateEmergencyContact, + paramShape: (req) => ({ ...req.body, contactId: req.params.contactId }), + })); + + router.put(...mobileCommand('/staff/profile/tax-forms/:formType', { + schema: taxFormDraftSchema, + policyAction: 'staff.profile.write', + resource: 'staff_document', + handler: handlers.saveTaxFormDraft, + paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }), + })); + + router.post(...mobileCommand('/staff/profile/tax-forms/:formType/submit', { + schema: taxFormSubmitSchema, + policyAction: 'staff.profile.write', + resource: 'staff_document', + handler: handlers.submitTaxForm, + paramShape: (req) => ({ ...req.body, formType: `${req.params.formType}`.toUpperCase() }), + })); + + router.post(...mobileCommand('/staff/profile/bank-accounts', { + schema: bankAccountCreateSchema, + policyAction: 'staff.profile.write', + resource: 'account', + handler: handlers.addStaffBankAccount, + })); + + router.put(...mobileCommand('/staff/profile/privacy', { + schema: privacyUpdateSchema, + policyAction: 'staff.profile.write', + resource: 'staff', + handler: handlers.updatePrivacyVisibility, + })); + + return router; +} diff --git a/backend/command-api/src/services/actor-context.js b/backend/command-api/src/services/actor-context.js new file mode 100644 index 00000000..30d23aa5 --- /dev/null +++ b/backend/command-api/src/services/actor-context.js @@ -0,0 +1,111 @@ +import { AppError } from '../lib/errors.js'; +import { query } from './db.js'; + +export async function loadActorContext(uid) { + const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([ + query( + ` + SELECT id AS "userId", email, display_name AS "displayName", phone, status + FROM users + WHERE id = $1 + `, + [uid] + ), + query( + ` + SELECT tm.id AS "membershipId", + tm.tenant_id AS "tenantId", + tm.base_role AS role, + t.name AS "tenantName", + t.slug AS "tenantSlug" + FROM tenant_memberships tm + JOIN tenants t ON t.id = tm.tenant_id + WHERE tm.user_id = $1 + AND tm.membership_status = 'ACTIVE' + ORDER BY tm.created_at ASC + LIMIT 1 + `, + [uid] + ), + query( + ` + SELECT bm.id AS "membershipId", + bm.business_id AS "businessId", + bm.business_role AS role, + b.business_name AS "businessName", + b.slug AS "businessSlug", + bm.tenant_id AS "tenantId" + FROM business_memberships bm + JOIN businesses b ON b.id = bm.business_id + WHERE bm.user_id = $1 + AND bm.membership_status = 'ACTIVE' + ORDER BY bm.created_at ASC + LIMIT 1 + `, + [uid] + ), + query( + ` + SELECT vm.id AS "membershipId", + vm.vendor_id AS "vendorId", + vm.vendor_role AS role, + v.company_name AS "vendorName", + v.slug AS "vendorSlug", + vm.tenant_id AS "tenantId" + FROM vendor_memberships vm + JOIN vendors v ON v.id = vm.vendor_id + WHERE vm.user_id = $1 + AND vm.membership_status = 'ACTIVE' + ORDER BY vm.created_at ASC + LIMIT 1 + `, + [uid] + ), + query( + ` + SELECT s.id AS "staffId", + s.tenant_id AS "tenantId", + s.full_name AS "fullName", + s.email, + s.phone, + s.primary_role AS "primaryRole", + s.onboarding_status AS "onboardingStatus", + s.status, + s.metadata, + w.id AS "workforceId", + w.vendor_id AS "vendorId", + w.workforce_number AS "workforceNumber" + FROM staffs s + LEFT JOIN workforce w ON w.staff_id = s.id + WHERE s.user_id = $1 + ORDER BY s.created_at ASC + LIMIT 1 + `, + [uid] + ), + ]); + + return { + user: userResult.rows[0] || null, + tenant: tenantResult.rows[0] || null, + business: businessResult.rows[0] || null, + vendor: vendorResult.rows[0] || null, + staff: staffResult.rows[0] || null, + }; +} + +export async function requireClientContext(uid) { + const context = await loadActorContext(uid); + if (!context.user || !context.tenant || !context.business) { + throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid }); + } + return context; +} + +export async function requireStaffContext(uid) { + const context = await loadActorContext(uid); + if (!context.user || !context.tenant || !context.staff) { + throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid }); + } + return context; +} diff --git a/backend/command-api/src/services/attendance-monitoring.js b/backend/command-api/src/services/attendance-monitoring.js new file mode 100644 index 00000000..caade1f2 --- /dev/null +++ b/backend/command-api/src/services/attendance-monitoring.js @@ -0,0 +1,84 @@ +export async function recordGeofenceIncident(client, { + assignment, + actorUserId, + locationStreamBatchId = null, + incidentType, + severity = 'WARNING', + status = 'OPEN', + effectiveClockInMode = null, + sourceType = null, + nfcTagUid = null, + deviceId = null, + latitude = null, + longitude = null, + accuracyMeters = null, + distanceToClockPointMeters = null, + withinGeofence = null, + overrideReason = null, + message = null, + occurredAt = null, + metadata = {}, +}) { + const result = await client.query( + ` + INSERT INTO geofence_incidents ( + tenant_id, + business_id, + vendor_id, + shift_id, + assignment_id, + staff_id, + actor_user_id, + location_stream_batch_id, + incident_type, + severity, + status, + effective_clock_in_mode, + source_type, + nfc_tag_uid, + device_id, + latitude, + longitude, + accuracy_meters, + distance_to_clock_point_meters, + within_geofence, + override_reason, + message, + occurred_at, + metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, COALESCE($23::timestamptz, NOW()), $24::jsonb + ) + RETURNING id + `, + [ + assignment.tenant_id, + assignment.business_id, + assignment.vendor_id, + assignment.shift_id, + assignment.id, + assignment.staff_id, + actorUserId, + locationStreamBatchId, + incidentType, + severity, + status, + effectiveClockInMode, + sourceType, + nfcTagUid, + deviceId, + latitude, + longitude, + accuracyMeters, + distanceToClockPointMeters, + withinGeofence, + overrideReason, + message, + occurredAt, + JSON.stringify(metadata || {}), + ] + ); + + return result.rows[0].id; +} diff --git a/backend/command-api/src/services/attendance-security-log-storage.js b/backend/command-api/src/services/attendance-security-log-storage.js new file mode 100644 index 00000000..06a1202c --- /dev/null +++ b/backend/command-api/src/services/attendance-security-log-storage.js @@ -0,0 +1,38 @@ +import { Storage } from '@google-cloud/storage'; + +const storage = new Storage(); + +function resolvePrivateBucket() { + return process.env.PRIVATE_BUCKET || null; +} + +export async function uploadAttendanceSecurityLog({ + tenantId, + staffId, + assignmentId, + proofId, + payload, +}) { + const bucket = resolvePrivateBucket(); + if (!bucket) { + return null; + } + + const objectPath = [ + 'attendance-security', + tenantId, + staffId, + assignmentId, + `${proofId}.json`, + ].join('/'); + + await storage.bucket(bucket).file(objectPath).save(JSON.stringify(payload), { + resumable: false, + contentType: 'application/json', + metadata: { + cacheControl: 'private, max-age=0', + }, + }); + + return `gs://${bucket}/${objectPath}`; +} diff --git a/backend/command-api/src/services/attendance-security.js b/backend/command-api/src/services/attendance-security.js new file mode 100644 index 00000000..f1ba8a8c --- /dev/null +++ b/backend/command-api/src/services/attendance-security.js @@ -0,0 +1,285 @@ +import crypto from 'node:crypto'; +import { AppError } from '../lib/errors.js'; +import { uploadAttendanceSecurityLog } from './attendance-security-log-storage.js'; + +function parseBooleanEnv(name, fallback = false) { + const value = process.env[name]; + if (value == null) return fallback; + return value === 'true'; +} + +function parseIntEnv(name, fallback) { + const parsed = Number.parseInt(`${process.env[name] || fallback}`, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function hashValue(value) { + if (!value) return null; + return crypto.createHash('sha256').update(`${value}`).digest('hex'); +} + +function normalizeTimestamp(value) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return null; + } + return date.toISOString(); +} + +function buildRequestFingerprint({ assignmentId, actorUserId, eventType, sourceType, deviceId, nfcTagUid, capturedAt }) { + const fingerprintSource = [assignmentId, actorUserId, eventType, sourceType, deviceId || '', nfcTagUid || '', capturedAt || ''].join('|'); + return hashValue(fingerprintSource); +} + +async function persistProofRecord(client, { + proofId, + assignment, + actor, + payload, + eventType, + proofNonce, + proofTimestamp, + requestFingerprint, + attestationProvider, + attestationTokenHash, + attestationStatus, + attestationReason, + objectUri, + metadata, +}) { + await client.query( + ` + INSERT INTO attendance_security_proofs ( + id, + tenant_id, + assignment_id, + shift_id, + staff_id, + actor_user_id, + event_type, + source_type, + device_id, + nfc_tag_uid, + proof_nonce, + proof_timestamp, + request_fingerprint, + attestation_provider, + attestation_token_hash, + attestation_status, + attestation_reason, + object_uri, + metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::timestamptz, $13, $14, $15, $16, $17, $18, $19::jsonb + ) + `, + [ + proofId, + assignment.tenant_id, + assignment.id, + assignment.shift_id, + assignment.staff_id, + actor.uid, + eventType, + payload.sourceType, + payload.deviceId || null, + payload.nfcTagUid || null, + proofNonce, + proofTimestamp, + requestFingerprint, + attestationProvider, + attestationTokenHash, + attestationStatus, + attestationReason, + objectUri, + JSON.stringify(metadata || {}), + ] + ); +} + +function buildBaseMetadata({ payload, capturedAt, securityCode = null, securityReason = null }) { + return { + capturedAt, + proofTimestamp: payload.proofTimestamp || null, + rawPayload: payload.rawPayload || {}, + securityCode, + securityReason, + notes: payload.notes || null, + }; +} + +export async function recordAttendanceSecurityProof(client, { + assignment, + actor, + payload, + eventType, + capturedAt, +}) { + const proofId = crypto.randomUUID(); + const proofNonce = payload.proofNonce || null; + const proofTimestamp = normalizeTimestamp(payload.proofTimestamp || payload.capturedAt || capturedAt); + const requestFingerprint = buildRequestFingerprint({ + assignmentId: assignment.id, + actorUserId: actor.uid, + eventType, + sourceType: payload.sourceType, + deviceId: payload.deviceId, + nfcTagUid: payload.nfcTagUid, + capturedAt, + }); + const attestationProvider = payload.attestationProvider || null; + const attestationTokenHash = hashValue(payload.attestationToken || null); + const requiresNonce = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_PROOF_NONCE', false); + const requiresDeviceId = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_DEVICE_ID', false); + const requiresAttestation = payload.sourceType === 'NFC' && parseBooleanEnv('NFC_ENFORCE_ATTESTATION', false); + const maxAgeSeconds = parseIntEnv('NFC_PROOF_MAX_AGE_SECONDS', 120); + const baseMetadata = buildBaseMetadata({ payload, capturedAt }); + + let securityCode = null; + let securityReason = null; + let attestationStatus = payload.sourceType === 'NFC' ? 'NOT_PROVIDED' : 'BYPASSED'; + let attestationReason = null; + + if (requiresDeviceId && !payload.deviceId) { + securityCode = 'DEVICE_ID_REQUIRED'; + securityReason = 'NFC proof must include a deviceId'; + } else if (requiresNonce && !proofNonce) { + securityCode = 'NFC_PROOF_NONCE_REQUIRED'; + securityReason = 'NFC proof must include a proofNonce'; + } else if (proofTimestamp) { + const skewSeconds = Math.abs(new Date(capturedAt).getTime() - new Date(proofTimestamp).getTime()) / 1000; + if (skewSeconds > maxAgeSeconds) { + securityCode = 'NFC_PROOF_TIMESTAMP_EXPIRED'; + securityReason = `NFC proof timestamp exceeded the ${maxAgeSeconds}-second window`; + } + } + + if (!securityCode && proofNonce) { + const replayCheck = await client.query( + ` + SELECT id + FROM attendance_security_proofs + WHERE tenant_id = $1 + AND proof_nonce = $2 + LIMIT 1 + `, + [assignment.tenant_id, proofNonce] + ); + if (replayCheck.rowCount > 0) { + securityCode = 'NFC_REPLAY_DETECTED'; + securityReason = 'This NFC proof nonce was already used'; + } + } + + if (payload.sourceType === 'NFC') { + if (attestationProvider || payload.attestationToken) { + if (!attestationProvider || !payload.attestationToken) { + securityCode = securityCode || 'ATTESTATION_PAYLOAD_INVALID'; + securityReason = securityReason || 'attestationProvider and attestationToken must be provided together'; + attestationStatus = 'REJECTED'; + attestationReason = 'Incomplete attestation payload'; + } else { + attestationStatus = 'RECORDED_UNVERIFIED'; + attestationReason = 'Attestation payload recorded; server-side verifier not yet enabled'; + } + } + + if (requiresAttestation && attestationStatus !== 'RECORDED_UNVERIFIED' && attestationStatus !== 'VERIFIED') { + securityCode = securityCode || 'ATTESTATION_REQUIRED'; + securityReason = securityReason || 'NFC proof requires device attestation'; + attestationStatus = 'REJECTED'; + attestationReason = 'Device attestation is required for NFC proof'; + } + + if (requiresAttestation && attestationStatus === 'RECORDED_UNVERIFIED') { + securityCode = securityCode || 'ATTESTATION_NOT_VERIFIED'; + securityReason = securityReason || 'NFC proof attestation cannot be trusted until verifier is enabled'; + attestationStatus = 'REJECTED'; + attestationReason = 'Recorded attestation is not yet verified'; + } + } + + const objectUri = await uploadAttendanceSecurityLog({ + tenantId: assignment.tenant_id, + staffId: assignment.staff_id, + assignmentId: assignment.id, + proofId, + payload: { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + staffId: assignment.staff_id, + actorUserId: actor.uid, + eventType, + sourceType: payload.sourceType, + proofNonce, + proofTimestamp, + deviceId: payload.deviceId || null, + nfcTagUid: payload.nfcTagUid || null, + requestFingerprint, + attestationProvider, + attestationTokenHash, + attestationStatus, + attestationReason, + capturedAt, + metadata: { + ...baseMetadata, + securityCode, + securityReason, + }, + }, + }); + + try { + await persistProofRecord(client, { + proofId, + assignment, + actor, + payload, + eventType, + proofNonce, + proofTimestamp, + requestFingerprint, + attestationProvider, + attestationTokenHash, + attestationStatus, + attestationReason, + objectUri, + metadata: { + ...baseMetadata, + securityCode, + securityReason, + }, + }); + } catch (error) { + if (error?.code === '23505' && proofNonce) { + throw new AppError('ATTENDANCE_SECURITY_FAILED', 'This NFC proof nonce was already used', 409, { + assignmentId: assignment.id, + proofNonce, + securityCode: 'NFC_REPLAY_DETECTED', + objectUri, + }); + } + throw error; + } + + if (securityCode) { + throw new AppError('ATTENDANCE_SECURITY_FAILED', securityReason, 409, { + assignmentId: assignment.id, + proofId, + proofNonce, + securityCode, + objectUri, + }); + } + + return { + proofId, + proofNonce, + proofTimestamp, + attestationStatus, + attestationReason, + objectUri, + }; +} diff --git a/backend/command-api/src/services/clock-in-policy.js b/backend/command-api/src/services/clock-in-policy.js new file mode 100644 index 00000000..648741b6 --- /dev/null +++ b/backend/command-api/src/services/clock-in-policy.js @@ -0,0 +1,203 @@ +export const CLOCK_IN_MODES = { + NFC_REQUIRED: 'NFC_REQUIRED', + GEO_REQUIRED: 'GEO_REQUIRED', + EITHER: 'EITHER', +}; + +function toRadians(value) { + return (value * Math.PI) / 180; +} + +export function distanceMeters(from, to) { + if ( + from?.latitude == null + || from?.longitude == null + || to?.latitude == null + || to?.longitude == null + ) { + return null; + } + + const earthRadiusMeters = 6371000; + const dLat = toRadians(Number(to.latitude) - Number(from.latitude)); + const dLon = toRadians(Number(to.longitude) - Number(from.longitude)); + const lat1 = toRadians(Number(from.latitude)); + const lat2 = toRadians(Number(to.latitude)); + + const a = Math.sin(dLat / 2) ** 2 + + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2; + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return Math.round(earthRadiusMeters * c); +} + +export function resolveEffectiveClockInPolicy(record = {}) { + return { + mode: record.clock_in_mode + || record.shift_clock_in_mode + || record.default_clock_in_mode + || CLOCK_IN_MODES.EITHER, + allowOverride: record.allow_clock_in_override + ?? record.shift_allow_clock_in_override + ?? record.default_allow_clock_in_override + ?? true, + }; +} + +function validateNfc(expectedNfcTag, payload) { + if (payload.sourceType !== 'NFC') { + return { + ok: false, + code: 'NFC_REQUIRED', + reason: 'Clock-in requires NFC', + overrideable: false, + }; + } + + if (!payload.nfcTagUid) { + return { + ok: false, + code: 'NFC_REQUIRED', + reason: 'NFC tag is required', + overrideable: false, + }; + } + + if (!expectedNfcTag) { + return { + ok: false, + code: 'NFC_NOT_CONFIGURED', + reason: 'Hub is not configured for NFC clock-in', + overrideable: false, + }; + } + + if (payload.nfcTagUid !== expectedNfcTag) { + return { + ok: false, + code: 'NFC_MISMATCH', + reason: 'NFC tag mismatch', + overrideable: false, + }; + } + + return { + ok: true, + distance: null, + withinGeofence: null, + }; +} + +function validateGeo(expectedPoint, radius, payload) { + if (payload.latitude == null || payload.longitude == null) { + return { + ok: false, + code: 'LOCATION_REQUIRED', + reason: 'Location coordinates are required', + overrideable: true, + distance: null, + withinGeofence: null, + }; + } + + if ( + expectedPoint?.latitude == null + || expectedPoint?.longitude == null + || radius == null + ) { + return { + ok: false, + code: 'GEOFENCE_NOT_CONFIGURED', + reason: 'Clock-in geofence is not configured', + overrideable: true, + distance: null, + withinGeofence: null, + }; + } + + const distance = distanceMeters({ + latitude: payload.latitude, + longitude: payload.longitude, + }, expectedPoint); + + if (distance == null) { + return { + ok: false, + code: 'LOCATION_REQUIRED', + reason: 'Location coordinates are required', + overrideable: true, + distance: null, + withinGeofence: null, + }; + } + + if (distance > radius) { + return { + ok: false, + code: 'OUTSIDE_GEOFENCE', + reason: `Outside geofence by ${distance - radius} meters`, + overrideable: true, + distance, + withinGeofence: false, + }; + } + + return { + ok: true, + distance, + withinGeofence: true, + }; +} + +export function evaluateClockInAttempt(record, payload) { + const policy = resolveEffectiveClockInPolicy(record); + const expectedPoint = { + latitude: record.expected_latitude, + longitude: record.expected_longitude, + }; + const radius = record.geofence_radius_meters; + const expectedNfcTag = record.expected_nfc_tag_uid; + + let proofResult; + if (policy.mode === CLOCK_IN_MODES.NFC_REQUIRED) { + proofResult = validateNfc(expectedNfcTag, payload); + } else if (policy.mode === CLOCK_IN_MODES.GEO_REQUIRED) { + proofResult = validateGeo(expectedPoint, radius, payload); + } else { + proofResult = payload.sourceType === 'NFC' + ? validateNfc(expectedNfcTag, payload) + : validateGeo(expectedPoint, radius, payload); + } + + if (proofResult.ok) { + return { + effectiveClockInMode: policy.mode, + allowOverride: policy.allowOverride, + validationStatus: 'ACCEPTED', + validationCode: null, + validationReason: null, + distance: proofResult.distance ?? null, + withinGeofence: proofResult.withinGeofence ?? null, + overrideUsed: false, + overrideable: false, + }; + } + + const rawOverrideReason = payload.overrideReason || payload.notes || null; + const overrideReason = typeof rawOverrideReason === 'string' ? rawOverrideReason.trim() : ''; + const canOverride = policy.allowOverride + && proofResult.overrideable === true + && overrideReason.length > 0; + + return { + effectiveClockInMode: policy.mode, + allowOverride: policy.allowOverride, + validationStatus: canOverride ? 'FLAGGED' : 'REJECTED', + validationCode: proofResult.code, + validationReason: proofResult.reason, + distance: proofResult.distance ?? null, + withinGeofence: proofResult.withinGeofence ?? null, + overrideUsed: canOverride, + overrideReason: overrideReason || null, + overrideable: proofResult.overrideable === true, + }; +} diff --git a/backend/command-api/src/services/command-service.js b/backend/command-api/src/services/command-service.js new file mode 100644 index 00000000..a9cce39b --- /dev/null +++ b/backend/command-api/src/services/command-service.js @@ -0,0 +1,1648 @@ +import { AppError } from '../lib/errors.js'; +import { withTransaction } from './db.js'; +import { recordGeofenceIncident } from './attendance-monitoring.js'; +import { recordAttendanceSecurityProof } from './attendance-security.js'; +import { evaluateClockInAttempt } from './clock-in-policy.js'; +import { enqueueHubManagerAlert } from './notification-outbox.js'; + +function toIsoOrNull(value) { + return value ? new Date(value).toISOString() : null; +} + +const ACTIVE_ASSIGNMENT_STATUSES = new Set([ + 'ASSIGNED', + 'ACCEPTED', + 'CHECKED_IN', + 'CHECKED_OUT', + 'COMPLETED', +]); + +const CANCELLABLE_ASSIGNMENT_STATUSES = ['ASSIGNED', 'ACCEPTED']; +const CANCELLABLE_APPLICATION_STATUSES = ['PENDING', 'CONFIRMED']; + +const SHIFT_STATUS_TRANSITIONS = { + DRAFT: new Set(['OPEN', 'CANCELLED']), + OPEN: new Set(['PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE', 'CANCELLED']), + PENDING_CONFIRMATION: new Set(['ASSIGNED', 'CANCELLED']), + ASSIGNED: new Set(['ACTIVE', 'COMPLETED', 'CANCELLED']), + ACTIVE: new Set(['COMPLETED', 'CANCELLED']), + COMPLETED: new Set([]), + CANCELLED: new Set([]), +}; + +async function ensureActorUser(client, actor) { + await client.query( + ` + INSERT INTO users (id, email, display_name, status) + VALUES ($1, $2, $3, 'ACTIVE') + ON CONFLICT (id) DO UPDATE + SET email = COALESCE(EXCLUDED.email, users.email), + display_name = COALESCE(EXCLUDED.display_name, users.display_name), + updated_at = NOW() + `, + [actor.uid, actor.email, actor.email] + ); +} + +async function insertDomainEvent(client, { + tenantId, + aggregateType, + aggregateId, + eventType, + actorUserId, + payload, +}) { + await client.query( + ` + INSERT INTO domain_events ( + tenant_id, + aggregate_type, + aggregate_id, + sequence, + event_type, + actor_user_id, + payload + ) + SELECT + $1, + $2, + $3, + COALESCE(MAX(sequence) + 1, 1), + $4, + $5, + $6::jsonb + FROM domain_events + WHERE tenant_id = $1 + AND aggregate_type = $2 + AND aggregate_id = $3 + `, + [tenantId, aggregateType, aggregateId, eventType, actorUserId, JSON.stringify(payload || {})] + ); +} + +async function requireBusiness(client, tenantId, businessId) { + const result = await client.query( + ` + SELECT id, business_name + FROM businesses + WHERE id = $1 AND tenant_id = $2 + `, + [businessId, tenantId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Business not found in tenant scope', 404, { + tenantId, + businessId, + }); + } + return result.rows[0]; +} + +async function requireVendor(client, tenantId, vendorId) { + const result = await client.query( + ` + SELECT id, company_name + FROM vendors + WHERE id = $1 AND tenant_id = $2 + `, + [vendorId, tenantId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Vendor not found in tenant scope', 404, { + tenantId, + vendorId, + }); + } + return result.rows[0]; +} + +async function requireShiftRole(client, shiftRoleId) { + const result = await client.query( + ` + SELECT sr.id, + sr.shift_id, + sr.role_code, + sr.role_name, + sr.workers_needed, + sr.assigned_count, + s.tenant_id, + s.business_id, + s.vendor_id + FROM shift_roles sr + JOIN shifts s ON s.id = sr.shift_id + WHERE sr.id = $1 + FOR UPDATE + `, + [shiftRoleId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Shift role not found', 404, { shiftRoleId }); + } + return result.rows[0]; +} + +async function requireAssignment(client, assignmentId) { + const result = await client.query( + ` + SELECT a.id, + a.tenant_id, + a.business_id, + a.vendor_id, + a.shift_id, + a.shift_role_id, + a.workforce_id, + a.staff_id, + a.status, + s.clock_point_id, + s.title AS shift_title, + s.starts_at, + s.ends_at, + s.clock_in_mode, + s.allow_clock_in_override, + cp.nfc_tag_uid AS expected_nfc_tag_uid, + COALESCE(s.latitude, cp.latitude) AS expected_latitude, + COALESCE(s.longitude, cp.longitude) AS expected_longitude, + COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS geofence_radius_meters, + cp.default_clock_in_mode, + cp.allow_clock_in_override AS default_allow_clock_in_override + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.id = $1 + FOR UPDATE OF a, s + `, + [assignmentId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Assignment not found', 404, { assignmentId }); + } + return result.rows[0]; +} + +async function requireOrder(client, tenantId, orderId) { + const result = await client.query( + ` + SELECT id, + tenant_id, + business_id, + vendor_id, + order_number, + status, + starts_at, + ends_at + FROM orders + WHERE id = $1 + AND tenant_id = $2 + FOR UPDATE + `, + [orderId, tenantId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found in tenant scope', 404, { + tenantId, + orderId, + }); + } + + return result.rows[0]; +} + +async function requireShift(client, tenantId, shiftId) { + const result = await client.query( + ` + SELECT id, + tenant_id, + order_id, + business_id, + vendor_id, + status, + starts_at, + ends_at, + required_workers, + assigned_workers + FROM shifts + WHERE id = $1 + AND tenant_id = $2 + FOR UPDATE + `, + [shiftId, tenantId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Shift not found in tenant scope', 404, { + tenantId, + shiftId, + }); + } + + return result.rows[0]; +} + +async function requireWorkforce(client, tenantId, workforceId) { + const result = await client.query( + ` + SELECT id, tenant_id, vendor_id, staff_id, status + FROM workforce + WHERE id = $1 + AND tenant_id = $2 + AND status = 'ACTIVE' + `, + [workforceId, tenantId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Workforce record not found', 404, { + tenantId, + workforceId, + }); + } + + return result.rows[0]; +} + +async function findAssignmentForShiftRoleWorkforce(client, shiftRoleId, workforceId) { + const result = await client.query( + ` + SELECT id, + tenant_id, + shift_id, + shift_role_id, + workforce_id, + staff_id, + application_id, + status + FROM assignments + WHERE shift_role_id = $1 + AND workforce_id = $2 + FOR UPDATE + `, + [shiftRoleId, workforceId] + ); + + return result.rows[0] || null; +} + +async function requireApplication(client, tenantId, applicationId) { + const result = await client.query( + ` + SELECT id, + tenant_id, + shift_id, + shift_role_id, + staff_id, + status + FROM applications + WHERE id = $1 + AND tenant_id = $2 + FOR UPDATE + `, + [applicationId, tenantId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Application not found in tenant scope', 404, { + tenantId, + applicationId, + }); + } + + return result.rows[0]; +} + +async function refreshShiftRoleCounts(client, shiftRoleId) { + await client.query( + ` + UPDATE shift_roles sr + SET assigned_count = counts.assigned_count, + updated_at = NOW() + FROM ( + SELECT $1::uuid AS shift_role_id, + COUNT(*) FILTER ( + WHERE status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + )::INTEGER AS assigned_count + FROM assignments + WHERE shift_role_id = $1 + ) counts + WHERE sr.id = counts.shift_role_id + `, + [shiftRoleId] + ); +} + +async function refreshShiftCounts(client, shiftId) { + await client.query( + ` + UPDATE shifts s + SET assigned_workers = counts.assigned_workers, + updated_at = NOW() + FROM ( + SELECT $1::uuid AS shift_id, + COUNT(*) FILTER ( + WHERE status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + )::INTEGER AS assigned_workers + FROM assignments + WHERE shift_id = $1 + ) counts + WHERE s.id = counts.shift_id + `, + [shiftId] + ); +} + +async function transitionShiftStatus(client, shiftId, fromStatus, toStatus) { + if (fromStatus === toStatus) { + return toStatus; + } + + const allowed = SHIFT_STATUS_TRANSITIONS[fromStatus] || new Set(); + if (!allowed.has(toStatus)) { + throw new AppError('INVALID_SHIFT_STATUS_TRANSITION', 'Invalid shift status transition', 409, { + shiftId, + fromStatus, + toStatus, + }); + } + + await client.query( + ` + UPDATE shifts + SET status = $2, + updated_at = NOW() + WHERE id = $1 + `, + [shiftId, toStatus] + ); + + return toStatus; +} + +function assertChronologicalWindow(startsAt, endsAt, code = 'VALIDATION_ERROR') { + if (startsAt && endsAt && new Date(startsAt) >= new Date(endsAt)) { + throw new AppError(code, 'start time must be earlier than end time', 400); + } +} + +function buildOrderUpdateStatement(payload) { + const fieldSpecs = [ + ['vendorId', 'vendor_id', (value) => value], + ['title', 'title', (value) => value], + ['description', 'description', (value) => value], + ['status', 'status', (value) => value], + ['serviceType', 'service_type', (value) => value], + ['startsAt', 'starts_at', (value) => toIsoOrNull(value)], + ['endsAt', 'ends_at', (value) => toIsoOrNull(value)], + ['locationName', 'location_name', (value) => value], + ['locationAddress', 'location_address', (value) => value], + ['latitude', 'latitude', (value) => value], + ['longitude', 'longitude', (value) => value], + ['notes', 'notes', (value) => value], + ['metadata', 'metadata', (value) => JSON.stringify(value || {})], + ]; + + const updates = []; + const values = []; + + for (const [inputKey, column, transform] of fieldSpecs) { + if (!Object.prototype.hasOwnProperty.call(payload, inputKey)) { + continue; + } + values.push(transform(payload[inputKey])); + const placeholder = `$${values.length}`; + updates.push( + column === 'metadata' + ? `${column} = ${placeholder}::jsonb` + : `${column} = ${placeholder}` + ); + } + + if (updates.length === 0) { + throw new AppError('VALIDATION_ERROR', 'At least one mutable order field must be provided', 400); + } + + updates.push('updated_at = NOW()'); + return { updates, values }; +} + +export async function createOrder(actor, payload) { + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + await requireBusiness(client, payload.tenantId, payload.businessId); + if (payload.vendorId) { + await requireVendor(client, payload.tenantId, payload.vendorId); + } + + const orderResult = await client.query( + ` + INSERT INTO orders ( + tenant_id, + business_id, + vendor_id, + order_number, + title, + description, + status, + service_type, + starts_at, + ends_at, + location_name, + location_address, + latitude, + longitude, + notes, + created_by_user_id, + metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, COALESCE($7, 'OPEN'), COALESCE($8, 'EVENT'), + $9, $10, $11, $12, $13, $14, $15, $16, $17::jsonb + ) + RETURNING id, order_number, status + `, + [ + payload.tenantId, + payload.businessId, + payload.vendorId || null, + payload.orderNumber, + payload.title, + payload.description || null, + payload.status || null, + payload.serviceType || null, + toIsoOrNull(payload.startsAt), + toIsoOrNull(payload.endsAt), + payload.locationName || null, + payload.locationAddress || null, + payload.latitude ?? null, + payload.longitude ?? null, + payload.notes || null, + actor.uid, + JSON.stringify(payload.metadata || {}), + ] + ); + + const order = orderResult.rows[0]; + const createdShifts = []; + + for (const shiftInput of payload.shifts) { + const shiftResult = await client.query( + ` + INSERT INTO shifts ( + tenant_id, + order_id, + business_id, + vendor_id, + clock_point_id, + shift_code, + title, + status, + starts_at, + ends_at, + timezone, + location_name, + location_address, + latitude, + longitude, + geofence_radius_meters, + required_workers, + assigned_workers, + notes, + metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, COALESCE($8, 'OPEN'), $9, $10, COALESCE($11, 'UTC'), + $12, $13, $14, $15, $16, $17, 0, $18, $19::jsonb + ) + RETURNING id, shift_code, title, required_workers + `, + [ + payload.tenantId, + order.id, + payload.businessId, + payload.vendorId || null, + shiftInput.clockPointId || null, + shiftInput.shiftCode, + shiftInput.title, + shiftInput.status || null, + toIsoOrNull(shiftInput.startsAt), + toIsoOrNull(shiftInput.endsAt), + shiftInput.timezone || null, + shiftInput.locationName || payload.locationName || null, + shiftInput.locationAddress || payload.locationAddress || null, + shiftInput.latitude ?? payload.latitude ?? null, + shiftInput.longitude ?? payload.longitude ?? null, + shiftInput.geofenceRadiusMeters ?? null, + shiftInput.requiredWorkers, + shiftInput.notes || null, + JSON.stringify(shiftInput.metadata || {}), + ] + ); + + const shift = shiftResult.rows[0]; + + for (const roleInput of shiftInput.roles) { + await client.query( + ` + INSERT INTO shift_roles ( + shift_id, + role_id, + role_code, + role_name, + workers_needed, + assigned_count, + pay_rate_cents, + bill_rate_cents, + metadata + ) + VALUES ($1, $2, $3, $4, $5, 0, $6, $7, $8::jsonb) + `, + [ + shift.id, + roleInput.roleId || null, + roleInput.roleCode, + roleInput.roleName, + roleInput.workersNeeded, + roleInput.payRateCents || 0, + roleInput.billRateCents || 0, + JSON.stringify(roleInput.metadata || {}), + ] + ); + } + + createdShifts.push(shift); + } + + await insertDomainEvent(client, { + tenantId: payload.tenantId, + aggregateType: 'order', + aggregateId: order.id, + eventType: 'ORDER_CREATED', + actorUserId: actor.uid, + payload: { + orderId: order.id, + shiftCount: createdShifts.length, + }, + }); + + return { + orderId: order.id, + orderNumber: order.order_number, + status: order.status, + shiftCount: createdShifts.length, + shiftIds: createdShifts.map((shift) => shift.id), + }; + }); +} + +export async function acceptShift(actor, payload) { + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + + const shiftRole = await requireShiftRole(client, payload.shiftRoleId); + if (payload.shiftId && shiftRole.shift_id !== payload.shiftId) { + throw new AppError('VALIDATION_ERROR', 'shiftId does not match shiftRoleId', 400, { + shiftId: payload.shiftId, + shiftRoleId: payload.shiftRoleId, + }); + } + + if (shiftRole.assigned_count >= shiftRole.workers_needed) { + const existingFilledAssignment = await findAssignmentForShiftRoleWorkforce( + client, + shiftRole.id, + payload.workforceId + ); + if (!existingFilledAssignment || existingFilledAssignment.status === 'CANCELLED') { + throw new AppError('SHIFT_ROLE_FILLED', 'Shift role is already filled', 409, { + shiftRoleId: payload.shiftRoleId, + }); + } + } + + const workforce = await requireWorkforce(client, shiftRole.tenant_id, payload.workforceId); + const existingAssignment = await findAssignmentForShiftRoleWorkforce(client, shiftRole.id, workforce.id); + + let assignment; + if (existingAssignment) { + if (existingAssignment.status === 'CANCELLED') { + throw new AppError('ASSIGNMENT_CANCELLED', 'Cancelled assignment cannot be accepted', 409, { + assignmentId: existingAssignment.id, + }); + } + + if (['ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED'].includes(existingAssignment.status)) { + assignment = existingAssignment; + } else { + const updatedAssignment = await client.query( + ` + UPDATE assignments + SET status = 'ACCEPTED', + accepted_at = COALESCE(accepted_at, NOW()), + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + RETURNING id, status + `, + [existingAssignment.id, JSON.stringify(payload.metadata || {})] + ); + assignment = updatedAssignment.rows[0]; + } + } else { + const assignmentResult = await client.query( + ` + INSERT INTO assignments ( + tenant_id, + business_id, + vendor_id, + shift_id, + shift_role_id, + workforce_id, + staff_id, + status, + assigned_at, + accepted_at, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'ACCEPTED', NOW(), NOW(), $8::jsonb) + RETURNING id, status + `, + [ + shiftRole.tenant_id, + shiftRole.business_id, + shiftRole.vendor_id, + shiftRole.shift_id, + shiftRole.id, + workforce.id, + workforce.staff_id, + JSON.stringify(payload.metadata || {}), + ] + ); + assignment = assignmentResult.rows[0]; + } + + await refreshShiftRoleCounts(client, shiftRole.id); + await refreshShiftCounts(client, shiftRole.shift_id); + + const shift = await requireShift(client, shiftRole.tenant_id, shiftRole.shift_id); + if (['OPEN', 'PENDING_CONFIRMATION'].includes(shift.status)) { + await transitionShiftStatus(client, shift.id, shift.status, 'ASSIGNED'); + } + + await insertDomainEvent(client, { + tenantId: shiftRole.tenant_id, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'SHIFT_ACCEPTED', + actorUserId: actor.uid, + payload: { + shiftId: shiftRole.shift_id, + shiftRoleId: shiftRole.id, + workforceId: workforce.id, + }, + }); + + return { + assignmentId: assignment.id, + shiftId: shiftRole.shift_id, + shiftRoleId: shiftRole.id, + status: assignment.status, + }; + }); +} + +export async function updateOrder(actor, payload) { + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const existingOrder = await requireOrder(client, payload.tenantId, payload.orderId); + + if (Object.prototype.hasOwnProperty.call(payload, 'vendorId') && payload.vendorId) { + await requireVendor(client, payload.tenantId, payload.vendorId); + } + + const nextStartsAt = Object.prototype.hasOwnProperty.call(payload, 'startsAt') + ? payload.startsAt + : existingOrder.starts_at; + const nextEndsAt = Object.prototype.hasOwnProperty.call(payload, 'endsAt') + ? payload.endsAt + : existingOrder.ends_at; + assertChronologicalWindow(nextStartsAt, nextEndsAt); + + const { updates, values } = buildOrderUpdateStatement(payload); + values.push(payload.orderId, payload.tenantId); + const orderResult = await client.query( + ` + UPDATE orders + SET ${updates.join(', ')} + WHERE id = $${values.length - 1} + AND tenant_id = $${values.length} + RETURNING id, order_number, status, updated_at + `, + values + ); + + const order = orderResult.rows[0]; + await insertDomainEvent(client, { + tenantId: payload.tenantId, + aggregateType: 'order', + aggregateId: order.id, + eventType: 'ORDER_UPDATED', + actorUserId: actor.uid, + payload: { + updatedFields: Object.keys(payload).filter((key) => !['orderId', 'tenantId'].includes(key)), + }, + }); + + return { + orderId: order.id, + orderNumber: order.order_number, + status: order.status, + updatedAt: order.updated_at, + }; + }); +} + +export async function cancelOrder(actor, payload) { + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const order = await requireOrder(client, payload.tenantId, payload.orderId); + + if (order.status === 'CANCELLED') { + return { + orderId: order.id, + orderNumber: order.order_number, + status: order.status, + alreadyCancelled: true, + }; + } + + const blockingAssignments = await client.query( + ` + SELECT a.id + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + WHERE s.order_id = $1 + AND a.status IN ('CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + LIMIT 1 + `, + [order.id] + ); + + if (blockingAssignments.rowCount > 0) { + throw new AppError('ORDER_CANCEL_BLOCKED', 'Order has active or completed assignments and cannot be cancelled', 409, { + orderId: order.id, + }); + } + + await client.query( + ` + UPDATE orders + SET status = 'CANCELLED', + notes = CASE + WHEN $2::text IS NULL THEN notes + WHEN notes IS NULL OR notes = '' THEN $2::text + ELSE CONCAT(notes, E'\\n', $2::text) + END, + metadata = COALESCE(metadata, '{}'::jsonb) || $3::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [order.id, payload.reason || null, JSON.stringify(payload.metadata || {})] + ); + + const affectedShiftIds = await client.query( + ` + SELECT id + FROM shifts + WHERE order_id = $1 + `, + [order.id] + ); + + await client.query( + ` + UPDATE shifts + SET status = CASE + WHEN status = 'COMPLETED' THEN status + ELSE 'CANCELLED' + END, + updated_at = NOW() + WHERE order_id = $1 + `, + [order.id] + ); + + await client.query( + ` + UPDATE assignments + SET status = 'CANCELLED', + updated_at = NOW() + WHERE shift_id IN (SELECT id FROM shifts WHERE order_id = $1) + AND status = ANY($2::text[]) + `, + [order.id, CANCELLABLE_ASSIGNMENT_STATUSES] + ); + + await client.query( + ` + UPDATE applications + SET status = 'CANCELLED', + updated_at = NOW() + WHERE shift_id IN (SELECT id FROM shifts WHERE order_id = $1) + AND status = ANY($2::text[]) + `, + [order.id, CANCELLABLE_APPLICATION_STATUSES] + ); + + for (const row of affectedShiftIds.rows) { + const roleIds = await client.query( + 'SELECT id FROM shift_roles WHERE shift_id = $1', + [row.id] + ); + for (const role of roleIds.rows) { + await refreshShiftRoleCounts(client, role.id); + } + await refreshShiftCounts(client, row.id); + } + + await insertDomainEvent(client, { + tenantId: payload.tenantId, + aggregateType: 'order', + aggregateId: order.id, + eventType: 'ORDER_CANCELLED', + actorUserId: actor.uid, + payload: { + reason: payload.reason || null, + }, + }); + + return { + orderId: order.id, + orderNumber: order.order_number, + status: 'CANCELLED', + cancelledShiftCount: affectedShiftIds.rowCount, + }; + }); +} + +export async function changeShiftStatus(actor, payload) { + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const shift = await requireShift(client, payload.tenantId, payload.shiftId); + + if (payload.status === 'COMPLETED') { + const openSession = await client.query( + ` + SELECT id + FROM attendance_sessions + WHERE assignment_id IN (SELECT id FROM assignments WHERE shift_id = $1) + AND status = 'OPEN' + LIMIT 1 + `, + [shift.id] + ); + if (openSession.rowCount > 0) { + throw new AppError('SHIFT_COMPLETE_BLOCKED', 'Shift has open attendance sessions', 409, { + shiftId: shift.id, + }); + } + } + + const nextStatus = await transitionShiftStatus(client, shift.id, shift.status, payload.status); + + if (nextStatus === 'CANCELLED') { + await client.query( + ` + UPDATE assignments + SET status = 'CANCELLED', + updated_at = NOW() + WHERE shift_id = $1 + AND status = ANY($2::text[]) + `, + [shift.id, CANCELLABLE_ASSIGNMENT_STATUSES] + ); + await client.query( + ` + UPDATE applications + SET status = 'CANCELLED', + updated_at = NOW() + WHERE shift_id = $1 + AND status = ANY($2::text[]) + `, + [shift.id, CANCELLABLE_APPLICATION_STATUSES] + ); + } + + if (nextStatus === 'COMPLETED') { + await client.query( + ` + UPDATE assignments + SET status = 'COMPLETED', + updated_at = NOW() + WHERE shift_id = $1 + AND status IN ('CHECKED_OUT', 'ACCEPTED') + `, + [shift.id] + ); + } + + const roleIds = await client.query('SELECT id FROM shift_roles WHERE shift_id = $1', [shift.id]); + for (const role of roleIds.rows) { + await refreshShiftRoleCounts(client, role.id); + } + await refreshShiftCounts(client, shift.id); + + await insertDomainEvent(client, { + tenantId: payload.tenantId, + aggregateType: 'shift', + aggregateId: shift.id, + eventType: 'SHIFT_STATUS_CHANGED', + actorUserId: actor.uid, + payload: { + fromStatus: shift.status, + toStatus: nextStatus, + reason: payload.reason || null, + metadata: payload.metadata || {}, + }, + }); + + return { + shiftId: shift.id, + orderId: shift.order_id, + status: nextStatus, + }; + }); +} + +export async function assignStaffToShift(actor, payload) { + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const shift = await requireShift(client, payload.tenantId, payload.shiftId); + const shiftRole = await requireShiftRole(client, payload.shiftRoleId); + + if (shiftRole.shift_id !== shift.id) { + throw new AppError('VALIDATION_ERROR', 'shiftId does not match shiftRoleId', 400, { + shiftId: payload.shiftId, + shiftRoleId: payload.shiftRoleId, + }); + } + + const workforce = await requireWorkforce(client, payload.tenantId, payload.workforceId); + let application = null; + if (payload.applicationId) { + application = await requireApplication(client, payload.tenantId, payload.applicationId); + if (application.shift_id !== shift.id || application.shift_role_id !== shiftRole.id || application.staff_id !== workforce.staff_id) { + throw new AppError('VALIDATION_ERROR', 'applicationId does not match shift role and workforce staff', 400, { + applicationId: payload.applicationId, + }); + } + } + + const existingAssignment = await findAssignmentForShiftRoleWorkforce(client, shiftRole.id, workforce.id); + if (existingAssignment && existingAssignment.status !== 'CANCELLED') { + return { + assignmentId: existingAssignment.id, + shiftId: shift.id, + shiftRoleId: shiftRole.id, + status: existingAssignment.status, + existing: true, + }; + } + + if (shiftRole.assigned_count >= shiftRole.workers_needed) { + throw new AppError('SHIFT_ROLE_FILLED', 'Shift role is already filled', 409, { + shiftRoleId: shiftRole.id, + }); + } + + const assignmentResult = await client.query( + ` + INSERT INTO assignments ( + tenant_id, + business_id, + vendor_id, + shift_id, + shift_role_id, + workforce_id, + staff_id, + application_id, + status, + assigned_at, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ASSIGNED', NOW(), $9::jsonb) + RETURNING id, status + `, + [ + shift.tenant_id, + shift.business_id, + shift.vendor_id, + shift.id, + shiftRole.id, + workforce.id, + workforce.staff_id, + application?.id || null, + JSON.stringify(payload.metadata || {}), + ] + ); + + if (application) { + await client.query( + ` + UPDATE applications + SET status = 'CONFIRMED', + updated_at = NOW() + WHERE id = $1 + `, + [application.id] + ); + } + + await refreshShiftRoleCounts(client, shiftRole.id); + await refreshShiftCounts(client, shift.id); + + if (['OPEN', 'PENDING_CONFIRMATION'].includes(shift.status)) { + await transitionShiftStatus(client, shift.id, shift.status, 'ASSIGNED'); + } + + const assignment = assignmentResult.rows[0]; + await insertDomainEvent(client, { + tenantId: payload.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'STAFF_ASSIGNED', + actorUserId: actor.uid, + payload: { + shiftId: shift.id, + shiftRoleId: shiftRole.id, + workforceId: workforce.id, + applicationId: application?.id || null, + }, + }); + + return { + assignmentId: assignment.id, + shiftId: shift.id, + shiftRoleId: shiftRole.id, + status: assignment.status, + existing: false, + }; + }); +} + +async function createAttendanceEvent(actor, payload, eventType) { + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await requireAssignment(client, payload.assignmentId); + const capturedAt = toIsoOrNull(payload.capturedAt) || new Date().toISOString(); + let securityProof = null; + + async function rejectAttendanceAttempt({ + errorCode, + reason, + incidentType = 'CLOCK_IN_REJECTED', + severity = 'WARNING', + effectiveClockInMode = null, + distance = null, + withinGeofence = null, + metadata = {}, + details = {}, + }) { + const incidentId = await recordGeofenceIncident(client, { + assignment, + actorUserId: actor.uid, + incidentType, + severity, + effectiveClockInMode, + sourceType: payload.sourceType, + nfcTagUid: payload.nfcTagUid || null, + deviceId: payload.deviceId || null, + latitude: payload.latitude ?? null, + longitude: payload.longitude ?? null, + accuracyMeters: payload.accuracyMeters ?? null, + distanceToClockPointMeters: distance, + withinGeofence, + overrideReason: payload.overrideReason || null, + message: reason, + occurredAt: capturedAt, + metadata: { + eventType, + ...metadata, + }, + }); + const rejectedEvent = await client.query( + ` + INSERT INTO attendance_events ( + tenant_id, + assignment_id, + shift_id, + staff_id, + clock_point_id, + event_type, + source_type, + source_reference, + nfc_tag_uid, + device_id, + latitude, + longitude, + accuracy_meters, + distance_to_clock_point_meters, + within_geofence, + validation_status, + validation_reason, + captured_at, + raw_payload + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'REJECTED', $16, $17, $18::jsonb + ) + RETURNING id + `, + [ + assignment.tenant_id, + assignment.id, + assignment.shift_id, + assignment.staff_id, + assignment.clock_point_id, + eventType, + payload.sourceType, + payload.sourceReference || null, + payload.nfcTagUid || null, + payload.deviceId || null, + payload.latitude ?? null, + payload.longitude ?? null, + payload.accuracyMeters ?? null, + distance, + withinGeofence, + reason, + capturedAt, + JSON.stringify({ + ...(payload.rawPayload || {}), + securityProofId: securityProof?.proofId || null, + securityObjectUri: securityProof?.objectUri || null, + }), + ] + ); + + await insertDomainEvent(client, { + tenantId: assignment.tenant_id, + aggregateType: 'attendance_event', + aggregateId: rejectedEvent.rows[0].id, + eventType: `${eventType}_REJECTED`, + actorUserId: actor.uid, + payload: { + assignmentId: assignment.id, + sourceType: payload.sourceType, + reason, + incidentId, + ...details, + }, + }); + + throw new AppError(errorCode, reason, 409, { + assignmentId: payload.assignmentId, + attendanceEventId: rejectedEvent.rows[0].id, + distanceToClockPointMeters: distance, + effectiveClockInMode, + ...details, + }); + } + + try { + securityProof = await recordAttendanceSecurityProof(client, { + assignment, + actor, + payload, + eventType, + capturedAt, + }); + } catch (error) { + if (!(error instanceof AppError) || error.code !== 'ATTENDANCE_SECURITY_FAILED') { + throw error; + } + + await rejectAttendanceAttempt({ + errorCode: error.code, + reason: error.message, + incidentType: 'CLOCK_IN_REJECTED', + severity: error.details?.securityCode?.startsWith('NFC') ? 'CRITICAL' : 'WARNING', + effectiveClockInMode: assignment.clock_in_mode || assignment.default_clock_in_mode || null, + metadata: { + securityCode: error.details?.securityCode || null, + }, + details: { + securityCode: error.details?.securityCode || null, + securityProofId: error.details?.proofId || null, + securityObjectUri: error.details?.objectUri || null, + }, + }); + } + + const validation = evaluateClockInAttempt(assignment, payload); + + if (validation.validationStatus === 'REJECTED') { + await rejectAttendanceAttempt({ + errorCode: 'ATTENDANCE_VALIDATION_FAILED', + reason: validation.validationReason, + incidentType: validation.validationCode === 'NFC_MISMATCH' + ? 'NFC_MISMATCH' + : 'CLOCK_IN_REJECTED', + severity: validation.validationCode === 'NFC_MISMATCH' ? 'CRITICAL' : 'WARNING', + effectiveClockInMode: validation.effectiveClockInMode, + distance: validation.distance, + withinGeofence: validation.withinGeofence, + metadata: { + validationCode: validation.validationCode, + }, + details: { + validationCode: validation.validationCode, + }, + }); + } + + const sessionResult = await client.query( + ` + SELECT id, status + FROM attendance_sessions + WHERE assignment_id = $1 + `, + [assignment.id] + ); + + if (eventType === 'CLOCK_IN' && sessionResult.rowCount > 0 && sessionResult.rows[0].status === 'OPEN') { + throw new AppError('ATTENDANCE_ALREADY_OPEN', 'Assignment already has an open attendance session', 409, { + assignmentId: assignment.id, + }); + } + + if (eventType === 'CLOCK_OUT' && sessionResult.rowCount === 0) { + throw new AppError('ATTENDANCE_NOT_OPEN', 'Assignment does not have an open attendance session', 409, { + assignmentId: assignment.id, + }); + } + + const eventResult = await client.query( + ` + INSERT INTO attendance_events ( + tenant_id, + assignment_id, + shift_id, + staff_id, + clock_point_id, + event_type, + source_type, + source_reference, + nfc_tag_uid, + device_id, + latitude, + longitude, + accuracy_meters, + distance_to_clock_point_meters, + within_geofence, + validation_status, + validation_reason, + captured_at, + raw_payload + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::jsonb + ) + RETURNING id, validation_status + `, + [ + assignment.tenant_id, + assignment.id, + assignment.shift_id, + assignment.staff_id, + assignment.clock_point_id, + eventType, + payload.sourceType, + payload.sourceReference || null, + payload.nfcTagUid || null, + payload.deviceId || null, + payload.latitude ?? null, + payload.longitude ?? null, + payload.accuracyMeters ?? null, + validation.distance, + validation.withinGeofence, + validation.validationStatus, + validation.overrideUsed ? validation.overrideReason : validation.validationReason, + capturedAt, + JSON.stringify({ + ...(payload.rawPayload || {}), + securityProofId: securityProof?.proofId || null, + securityAttestationStatus: securityProof?.attestationStatus || null, + securityObjectUri: securityProof?.objectUri || null, + }), + ] + ); + + if (validation.overrideUsed) { + const incidentId = await recordGeofenceIncident(client, { + assignment, + actorUserId: actor.uid, + incidentType: 'CLOCK_IN_OVERRIDE', + severity: 'WARNING', + effectiveClockInMode: validation.effectiveClockInMode, + sourceType: payload.sourceType, + nfcTagUid: payload.nfcTagUid || null, + deviceId: payload.deviceId || null, + latitude: payload.latitude ?? null, + longitude: payload.longitude ?? null, + accuracyMeters: payload.accuracyMeters ?? null, + distanceToClockPointMeters: validation.distance, + withinGeofence: validation.withinGeofence, + overrideReason: validation.overrideReason, + message: validation.validationReason, + occurredAt: capturedAt, + metadata: { + validationCode: validation.validationCode, + eventType, + }, + }); + + await enqueueHubManagerAlert(client, { + tenantId: assignment.tenant_id, + businessId: assignment.business_id, + shiftId: assignment.shift_id, + assignmentId: assignment.id, + hubId: assignment.clock_point_id, + relatedIncidentId: incidentId, + notificationType: 'CLOCK_IN_OVERRIDE_REVIEW', + priority: 'HIGH', + subject: 'Clock-in override requires review', + body: `${assignment.shift_title}: clock-in override submitted by ${actor.email || actor.uid}`, + payload: { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + staffId: assignment.staff_id, + reason: validation.overrideReason, + validationReason: validation.validationReason, + effectiveClockInMode: validation.effectiveClockInMode, + eventType, + }, + dedupeScope: incidentId, + }); + } + + let sessionId; + if (eventType === 'CLOCK_IN') { + const insertedSession = await client.query( + ` + INSERT INTO attendance_sessions ( + tenant_id, + assignment_id, + staff_id, + clock_in_event_id, + status, + check_in_at + ) + VALUES ($1, $2, $3, $4, 'OPEN', $5) + RETURNING id + `, + [assignment.tenant_id, assignment.id, assignment.staff_id, eventResult.rows[0].id, capturedAt] + ); + sessionId = insertedSession.rows[0].id; + await client.query( + ` + UPDATE assignments + SET status = 'CHECKED_IN', + checked_in_at = $2, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, capturedAt] + ); + } else { + const existingSession = sessionResult.rows[0]; + await client.query( + ` + UPDATE attendance_sessions + SET clock_out_event_id = $2, + status = 'CLOSED', + check_out_at = $3, + worked_minutes = GREATEST(EXTRACT(EPOCH FROM ($3 - check_in_at))::INTEGER / 60, 0), + updated_at = NOW() + WHERE id = $1 + `, + [existingSession.id, eventResult.rows[0].id, capturedAt] + ); + sessionId = existingSession.id; + await client.query( + ` + UPDATE assignments + SET status = 'CHECKED_OUT', + checked_out_at = $2, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, capturedAt] + ); + } + + await insertDomainEvent(client, { + tenantId: assignment.tenant_id, + aggregateType: 'attendance_event', + aggregateId: eventResult.rows[0].id, + eventType, + actorUserId: actor.uid, + payload: { + assignmentId: assignment.id, + sessionId, + sourceType: payload.sourceType, + validationStatus: validation.validationStatus, + }, + }); + + return { + attendanceEventId: eventResult.rows[0].id, + assignmentId: assignment.id, + sessionId, + status: eventType, + validationStatus: eventResult.rows[0].validation_status, + effectiveClockInMode: validation.effectiveClockInMode, + overrideUsed: validation.overrideUsed, + securityProofId: securityProof?.proofId || null, + attestationStatus: securityProof?.attestationStatus || null, + }; + }); +} + +export async function clockIn(actor, payload) { + return createAttendanceEvent(actor, payload, 'CLOCK_IN'); +} + +export async function clockOut(actor, payload) { + return createAttendanceEvent(actor, payload, 'CLOCK_OUT'); +} + +export async function addFavoriteStaff(actor, payload) { + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + await requireBusiness(client, payload.tenantId, payload.businessId); + + const staffResult = await client.query( + ` + SELECT id + FROM staffs + WHERE id = $1 AND tenant_id = $2 + `, + [payload.staffId, payload.tenantId] + ); + if (staffResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Staff not found in tenant scope', 404, { + staffId: payload.staffId, + }); + } + + const favoriteResult = await client.query( + ` + INSERT INTO staff_favorites ( + tenant_id, + business_id, + staff_id, + created_by_user_id + ) + VALUES ($1, $2, $3, $4) + ON CONFLICT (business_id, staff_id) DO UPDATE + SET created_by_user_id = EXCLUDED.created_by_user_id + RETURNING id + `, + [payload.tenantId, payload.businessId, payload.staffId, actor.uid] + ); + + await insertDomainEvent(client, { + tenantId: payload.tenantId, + aggregateType: 'staff_favorite', + aggregateId: favoriteResult.rows[0].id, + eventType: 'STAFF_FAVORITED', + actorUserId: actor.uid, + payload, + }); + + return { + favoriteId: favoriteResult.rows[0].id, + businessId: payload.businessId, + staffId: payload.staffId, + }; + }); +} + +export async function removeFavoriteStaff(actor, payload) { + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const deleted = await client.query( + ` + DELETE FROM staff_favorites + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + RETURNING id + `, + [payload.tenantId, payload.businessId, payload.staffId] + ); + + if (deleted.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Favorite staff record not found', 404, payload); + } + + await insertDomainEvent(client, { + tenantId: payload.tenantId, + aggregateType: 'staff_favorite', + aggregateId: deleted.rows[0].id, + eventType: 'STAFF_UNFAVORITED', + actorUserId: actor.uid, + payload, + }); + + return { + removed: true, + businessId: payload.businessId, + staffId: payload.staffId, + }; + }); +} + +export async function createStaffReview(actor, payload) { + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await requireAssignment(client, payload.assignmentId); + if (assignment.business_id !== payload.businessId || assignment.staff_id !== payload.staffId) { + throw new AppError('VALIDATION_ERROR', 'Assignment does not match business/staff review target', 400, { + assignmentId: payload.assignmentId, + businessId: payload.businessId, + staffId: payload.staffId, + }); + } + + const reviewResult = await client.query( + ` + INSERT INTO staff_reviews ( + tenant_id, + business_id, + staff_id, + assignment_id, + reviewer_user_id, + rating, + review_text, + tags + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + ON CONFLICT (business_id, assignment_id, staff_id) DO UPDATE + SET reviewer_user_id = EXCLUDED.reviewer_user_id, + rating = EXCLUDED.rating, + review_text = EXCLUDED.review_text, + tags = EXCLUDED.tags, + updated_at = NOW() + RETURNING id, rating + `, + [ + payload.tenantId, + payload.businessId, + payload.staffId, + payload.assignmentId, + actor.uid, + payload.rating, + payload.reviewText || null, + JSON.stringify(payload.tags || []), + ] + ); + + await client.query( + ` + UPDATE staffs + SET average_rating = review_stats.avg_rating, + rating_count = review_stats.rating_count, + updated_at = NOW() + FROM ( + SELECT staff_id, + ROUND(AVG(rating)::numeric, 2) AS avg_rating, + COUNT(*)::INTEGER AS rating_count + FROM staff_reviews + WHERE staff_id = $1 + GROUP BY staff_id + ) review_stats + WHERE staffs.id = review_stats.staff_id + `, + [payload.staffId] + ); + + await insertDomainEvent(client, { + tenantId: payload.tenantId, + aggregateType: 'staff_review', + aggregateId: reviewResult.rows[0].id, + eventType: 'STAFF_REVIEW_CREATED', + actorUserId: actor.uid, + payload: { + staffId: payload.staffId, + assignmentId: payload.assignmentId, + rating: payload.rating, + }, + }); + + return { + reviewId: reviewResult.rows[0].id, + assignmentId: payload.assignmentId, + staffId: payload.staffId, + rating: reviewResult.rows[0].rating, + }; + }); +} diff --git a/backend/command-api/src/services/db.js b/backend/command-api/src/services/db.js new file mode 100644 index 00000000..3fdfbba7 --- /dev/null +++ b/backend/command-api/src/services/db.js @@ -0,0 +1,105 @@ +import pg from 'pg'; + +const { Pool, types } = pg; + +function parseNumericDatabaseValue(value) { + if (value == null) return value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; +} + +types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue); +types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue); + +let pool; + +function parseIntOrDefault(value, fallback) { + const parsed = Number.parseInt(`${value || fallback}`, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +export function resolveDatabasePoolConfig({ + preferIdempotency = false, + maxEnvVar = 'DB_POOL_MAX', +} = {}) { + const primaryUrl = preferIdempotency + ? process.env.IDEMPOTENCY_DATABASE_URL || process.env.DATABASE_URL + : process.env.DATABASE_URL || process.env.IDEMPOTENCY_DATABASE_URL; + + if (primaryUrl) { + return { + connectionString: primaryUrl, + max: parseIntOrDefault(process.env[maxEnvVar], 10), + idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000), + }; + } + + const user = process.env.DB_USER; + const password = process.env.DB_PASSWORD; + const database = process.env.DB_NAME; + const host = process.env.DB_HOST || ( + process.env.INSTANCE_CONNECTION_NAME + ? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}` + : '' + ); + + if (!user || password == null || !database || !host) { + return null; + } + + return { + host, + port: parseIntOrDefault(process.env.DB_PORT, 5432), + user, + password, + database, + max: parseIntOrDefault(process.env[maxEnvVar], 10), + idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000), + }; +} + +export function isDatabaseConfigured() { + return Boolean(resolveDatabasePoolConfig()); +} + +function getPool() { + if (!pool) { + const resolved = resolveDatabasePoolConfig(); + if (!resolved) { + throw new Error('Database connection settings are required'); + } + pool = new Pool(resolved); + } + return pool; +} + +export async function query(text, params = []) { + return getPool().query(text, params); +} + +export async function withTransaction(work) { + const client = await getPool().connect(); + try { + await client.query('BEGIN'); + const result = await work(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +export async function checkDatabaseHealth() { + const result = await query('SELECT 1 AS ok'); + return result.rows[0]?.ok === 1; +} + +export async function closePool() { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/backend/command-api/src/services/firebase-admin.js b/backend/command-api/src/services/firebase-admin.js new file mode 100644 index 00000000..e5260629 --- /dev/null +++ b/backend/command-api/src/services/firebase-admin.js @@ -0,0 +1,19 @@ +import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; +import { getAuth } from 'firebase-admin/auth'; +import { getMessaging } from 'firebase-admin/messaging'; + +export function ensureFirebaseAdminApp() { + if (getApps().length === 0) { + initializeApp({ credential: applicationDefault() }); + } +} + +export function getFirebaseAdminAuth() { + ensureFirebaseAdminApp(); + return getAuth(); +} + +export function getFirebaseAdminMessaging() { + ensureFirebaseAdminApp(); + return getMessaging(); +} diff --git a/backend/command-api/src/services/firebase-auth.js b/backend/command-api/src/services/firebase-auth.js index e268d5db..6125b3e7 100644 --- a/backend/command-api/src/services/firebase-auth.js +++ b/backend/command-api/src/services/firebase-auth.js @@ -1,13 +1,5 @@ -import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; -import { getAuth } from 'firebase-admin/auth'; - -function ensureAdminApp() { - if (getApps().length === 0) { - initializeApp({ credential: applicationDefault() }); - } -} +import { getFirebaseAdminAuth } from './firebase-admin.js'; export async function verifyFirebaseToken(token) { - ensureAdminApp(); - return getAuth().verifyIdToken(token); + return getFirebaseAdminAuth().verifyIdToken(token); } diff --git a/backend/command-api/src/services/idempotency-store.js b/backend/command-api/src/services/idempotency-store.js index 8a3df3d4..5c34d76c 100644 --- a/backend/command-api/src/services/idempotency-store.js +++ b/backend/command-api/src/services/idempotency-store.js @@ -1,4 +1,5 @@ import { Pool } from 'pg'; +import { resolveDatabasePoolConfig } from './db.js'; const DEFAULT_TTL_SECONDS = Number.parseInt(process.env.IDEMPOTENCY_TTL_SECONDS || '86400', 10); const CLEANUP_EVERY_OPS = Number.parseInt(process.env.IDEMPOTENCY_CLEANUP_EVERY_OPS || '100', 10); @@ -12,9 +13,9 @@ function shouldUseSqlStore() { return false; } if (mode === 'sql') { - return true; + return Boolean(resolveDatabasePoolConfig({ preferIdempotency: true, maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX' })); } - return Boolean(process.env.IDEMPOTENCY_DATABASE_URL); + return Boolean(resolveDatabasePoolConfig({ preferIdempotency: true, maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX' })); } function gcExpiredMemoryRecords(now = Date.now()) { @@ -55,15 +56,16 @@ function createMemoryAdapter() { } async function createSqlAdapter() { - const connectionString = process.env.IDEMPOTENCY_DATABASE_URL; - if (!connectionString) { - throw new Error('IDEMPOTENCY_DATABASE_URL is required for sql idempotency store'); + const poolConfig = resolveDatabasePoolConfig({ + preferIdempotency: true, + maxEnvVar: 'IDEMPOTENCY_DB_POOL_MAX', + }); + + if (!poolConfig) { + throw new Error('Database connection settings are required for sql idempotency store'); } - const pool = new Pool({ - connectionString, - max: Number.parseInt(process.env.IDEMPOTENCY_DB_POOL_MAX || '5', 10), - }); + const pool = new Pool(poolConfig); await pool.query(` CREATE TABLE IF NOT EXISTS command_idempotency ( diff --git a/backend/command-api/src/services/location-log-storage.js b/backend/command-api/src/services/location-log-storage.js new file mode 100644 index 00000000..4c0600b8 --- /dev/null +++ b/backend/command-api/src/services/location-log-storage.js @@ -0,0 +1,38 @@ +import { Storage } from '@google-cloud/storage'; + +const storage = new Storage(); + +function resolvePrivateBucket() { + return process.env.PRIVATE_BUCKET || null; +} + +export async function uploadLocationBatch({ + tenantId, + staffId, + assignmentId, + batchId, + payload, +}) { + const bucket = resolvePrivateBucket(); + if (!bucket) { + return null; + } + + const objectPath = [ + 'location-streams', + tenantId, + staffId, + assignmentId, + `${batchId}.json`, + ].join('/'); + + await storage.bucket(bucket).file(objectPath).save(JSON.stringify(payload), { + resumable: false, + contentType: 'application/json', + metadata: { + cacheControl: 'private, max-age=0', + }, + }); + + return `gs://${bucket}/${objectPath}`; +} diff --git a/backend/command-api/src/services/mobile-command-service.js b/backend/command-api/src/services/mobile-command-service.js new file mode 100644 index 00000000..e0017cd5 --- /dev/null +++ b/backend/command-api/src/services/mobile-command-service.js @@ -0,0 +1,2867 @@ +import crypto from 'node:crypto'; +import { AppError } from '../lib/errors.js'; +import { query, withTransaction } from './db.js'; +import { loadActorContext, requireClientContext, requireStaffContext } from './actor-context.js'; +import { recordGeofenceIncident } from './attendance-monitoring.js'; +import { distanceMeters, resolveEffectiveClockInPolicy } from './clock-in-policy.js'; +import { uploadLocationBatch } from './location-log-storage.js'; +import { enqueueHubManagerAlert, enqueueUserAlert } from './notification-outbox.js'; +import { registerPushToken, unregisterPushToken } from './notification-device-tokens.js'; +import { + cancelOrder as cancelOrderCommand, + clockIn as clockInCommand, + clockOut as clockOutCommand, + createOrder as createOrderCommand, +} from './command-service.js'; + +function toIsoOrNull(value) { + return value ? new Date(value).toISOString() : null; +} + +function normalizeSlug(input) { + return `${input || ''}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); +} + +function normalizePhone(value) { + if (!value) return null; + return `${value}`.trim(); +} + +function ensureArray(value) { + return Array.isArray(value) ? value : []; +} + +function buildAssignmentReferencePayload(assignment) { + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + businessId: assignment.business_id, + vendorId: assignment.vendor_id, + staffId: assignment.staff_id, + clockPointId: assignment.clock_point_id, + }; +} + +function generateOrderNumber(prefix = 'ORD') { + const stamp = Date.now().toString().slice(-8); + const random = crypto.randomInt(100, 999); + return `${prefix}-${stamp}${random}`; +} + +function normalizeWorkerCount(position) { + return position.workerCount ?? position.workersNeeded ?? 1; +} + +function roleCodeFromName(name) { + return `${name || 'role'}` + .toUpperCase() + .replace(/[^A-Z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 50) || 'ROLE'; +} + +function toArrayOfUniqueIntegers(values, fallback) { + const source = Array.isArray(values) && values.length > 0 ? values : fallback; + return [...new Set(source.map((value) => Number(value)).filter((value) => Number.isInteger(value) && value >= 0 && value <= 6))]; +} + +function combineDateAndTime(dateValue, timeValue) { + const [hours, minutes] = `${timeValue}`.split(':').map((value) => Number.parseInt(value, 10)); + const date = new Date(`${dateValue}T00:00:00.000Z`); + if (Number.isNaN(date.getTime()) || Number.isNaN(hours) || Number.isNaN(minutes)) { + throw new AppError('VALIDATION_ERROR', 'Invalid date/time combination for order schedule', 400, { + date: dateValue, + time: timeValue, + }); + } + date.setUTCHours(hours, minutes, 0, 0); + return date; +} + +function buildShiftWindow(dateValue, startTime, endTime) { + const startsAt = combineDateAndTime(dateValue, startTime); + const endsAt = combineDateAndTime(dateValue, endTime); + if (endsAt <= startsAt) { + endsAt.setUTCDate(endsAt.getUTCDate() + 1); + } + return { + startsAt: startsAt.toISOString(), + endsAt: endsAt.toISOString(), + }; +} + +function materializeScheduleDates({ orderType, orderDate, startDate, endDate, recurrenceDays, daysOfWeek, horizonDays }) { + if (orderType === 'ONE_TIME') { + return [orderDate]; + } + + const from = new Date(`${startDate}T00:00:00.000Z`); + if (Number.isNaN(from.getTime())) { + throw new AppError('VALIDATION_ERROR', 'Invalid startDate', 400, { startDate }); + } + + const to = orderType === 'RECURRING' + ? new Date(`${endDate}T00:00:00.000Z`) + : (() => { + if (endDate) return new Date(`${endDate}T00:00:00.000Z`); + const fallback = new Date(from); + fallback.setUTCDate(fallback.getUTCDate() + (horizonDays || 28)); + return fallback; + })(); + + if (Number.isNaN(to.getTime()) || to < from) { + throw new AppError('VALIDATION_ERROR', 'Invalid scheduling window', 400, { + startDate, + endDate: endDate || null, + }); + } + + const activeDays = orderType === 'RECURRING' + ? toArrayOfUniqueIntegers(recurrenceDays, []) + : toArrayOfUniqueIntegers(daysOfWeek, [1, 2, 3, 4, 5]); + + const dates = []; + const cursor = new Date(from); + while (cursor <= to) { + if (activeDays.includes(cursor.getUTCDay())) { + dates.push(cursor.toISOString().slice(0, 10)); + } + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + if (dates.length === 0) { + throw new AppError('VALIDATION_ERROR', 'Schedule did not produce any shifts', 400, { + orderType, + startDate, + endDate: endDate || null, + activeDays, + }); + } + + return dates; +} + +async function loadHubDetails(tenantId, businessId, hubId) { + const result = await query( + ` + SELECT + cp.id, + cp.label, + cp.address, + cp.latitude, + cp.longitude, + cp.geofence_radius_meters, + cp.metadata + FROM clock_points cp + WHERE cp.tenant_id = $1 + AND cp.business_id = $2 + AND cp.id = $3 + AND cp.status = 'ACTIVE' + `, + [tenantId, businessId, hubId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Hub not found in business scope', 404, { hubId }); + } + + return result.rows[0]; +} + +async function resolveRoleCatalogEntries(tenantId, positions) { + const roleIds = positions.map((position) => position.roleId).filter(Boolean); + const roleCodes = positions.map((position) => position.roleCode).filter(Boolean); + if (roleIds.length === 0 && roleCodes.length === 0) { + return new Map(); + } + + const result = await query( + ` + SELECT id, code, name + FROM roles_catalog + WHERE tenant_id = $1 + AND status = 'ACTIVE' + AND ( + (cardinality($2::uuid[]) > 0 AND id = ANY($2::uuid[])) + OR + (cardinality($3::text[]) > 0 AND code = ANY($3::text[])) + ) + `, + [tenantId, roleIds, roleCodes] + ); + + const lookup = new Map(); + for (const row of result.rows) { + lookup.set(row.id, row); + lookup.set(row.code, row); + } + return lookup; +} + +function buildShiftRoleEntry(position, roleLookup) { + const role = roleLookup.get(position.roleId) || roleLookup.get(position.roleCode); + const workersNeeded = normalizeWorkerCount(position); + const billRateCents = position.billRateCents ?? position.hourlyRateCents ?? 0; + const payRateCents = position.payRateCents ?? position.hourlyRateCents ?? 0; + + return { + startTime: position.startTime, + endTime: position.endTime, + roleId: role?.id || position.roleId || null, + roleCode: role?.code || position.roleCode || roleCodeFromName(position.roleName), + roleName: role?.name || position.roleName || role?.name || 'Role', + workersNeeded, + payRateCents, + billRateCents, + metadata: { + lunchBreakMinutes: position.lunchBreakMinutes ?? 0, + paidBreak: position.paidBreak ?? false, + instantBook: position.instantBook ?? false, + ...position.metadata, + }, + }; +} + +function buildOrderShifts({ dates, positions, timezone, hub }) { + const shifts = []; + + for (const dateValue of dates) { + const buckets = new Map(); + for (const position of positions) { + const key = `${position.startTime}|${position.endTime}`; + const bucket = buckets.get(key) || []; + bucket.push(position); + buckets.set(key, bucket); + } + + let shiftIndex = 0; + for (const [timeKey, bucketPositions] of buckets.entries()) { + shiftIndex += 1; + const [startTime, endTime] = timeKey.split('|'); + const window = buildShiftWindow(dateValue, startTime, endTime); + const requiredWorkers = bucketPositions.reduce((sum, position) => sum + normalizeWorkerCount(position), 0); + shifts.push({ + shiftCode: `SFT-${dateValue.replaceAll('-', '')}-${shiftIndex}`, + title: `${hub.label} ${startTime}-${endTime}`, + startsAt: window.startsAt, + endsAt: window.endsAt, + timezone: timezone || 'UTC', + clockPointId: hub.id, + locationName: hub.label, + locationAddress: hub.address || null, + latitude: hub.latitude == null ? undefined : Number(hub.latitude), + longitude: hub.longitude == null ? undefined : Number(hub.longitude), + geofenceRadiusMeters: hub.geofence_radius_meters || undefined, + requiredWorkers, + metadata: { + city: hub.metadata?.city || null, + state: hub.metadata?.state || null, + zipCode: hub.metadata?.zipCode || null, + }, + roles: bucketPositions, + }); + } + } + + return shifts; +} + +function buildMobileOrderMetadata(orderType, payload, extra = {}) { + return { + orderType, + source: 'mobile-api', + recurrenceDays: payload.recurrenceDays || payload.daysOfWeek || null, + startDate: payload.startDate || payload.orderDate || null, + endDate: payload.endDate || null, + ...payload.metadata, + ...extra, + }; +} + +function inferAttendanceSourceType(payload) { + if (payload.sourceType) return payload.sourceType; + if (payload.nfcTagId) return 'NFC'; + if (payload.latitude != null && payload.longitude != null) return 'GEO'; + return 'MANUAL'; +} + +async function buildOrderCreatePayloadFromMobile(actor, context, payload, orderType, extra = {}) { + const hubId = payload.hubId || extra.hubId; + const hub = await loadHubDetails(context.tenant.tenantId, context.business.businessId, hubId); + const positionInputs = payload.positions || extra.positions || []; + const roleLookup = await resolveRoleCatalogEntries(context.tenant.tenantId, positionInputs); + const normalizedPositions = positionInputs.map((position) => buildShiftRoleEntry(position, roleLookup)); + const dates = materializeScheduleDates({ + orderType, + orderDate: payload.orderDate || extra.orderDate, + startDate: payload.startDate || extra.startDate, + endDate: payload.endDate || extra.endDate, + recurrenceDays: payload.recurrenceDays || extra.recurrenceDays, + daysOfWeek: payload.daysOfWeek || extra.daysOfWeek, + horizonDays: payload.horizonDays || extra.horizonDays, + }); + const shifts = buildOrderShifts({ + dates, + positions: normalizedPositions, + timezone: payload.timezone || extra.timezone, + hub, + }); + const startsAt = shifts[0]?.startsAt || null; + const endsAt = shifts[shifts.length - 1]?.endsAt || null; + return { + tenantId: context.tenant.tenantId, + businessId: context.business.businessId, + vendorId: payload.vendorId ?? extra.vendorId ?? null, + orderNumber: generateOrderNumber(orderType === 'ONE_TIME' ? 'ORD' : orderType === 'RECURRING' ? 'RCR' : 'PRM'), + title: payload.eventName || extra.eventName || 'Untitled Order', + description: payload.description ?? extra.description ?? null, + status: 'OPEN', + serviceType: payload.serviceType ?? extra.serviceType ?? 'EVENT', + startsAt, + endsAt, + locationName: hub.label, + locationAddress: hub.address || null, + latitude: hub.latitude == null ? undefined : Number(hub.latitude), + longitude: hub.longitude == null ? undefined : Number(hub.longitude), + notes: payload.notes ?? extra.notes ?? null, + metadata: buildMobileOrderMetadata(orderType, payload, extra.metadata || {}), + shifts: shifts.map((shift, index) => ({ + ...shift, + shiftCode: shift.shiftCode || `SFT-${index + 1}`, + roles: shift.roles.map((role) => ({ + roleId: role.roleId, + roleCode: role.roleCode, + roleName: role.roleName, + workersNeeded: role.workersNeeded, + payRateCents: role.payRateCents, + billRateCents: role.billRateCents, + metadata: role.metadata, + })), + })), + }; +} + +async function loadEditableOrderTemplate(actorUid, tenantId, businessId, orderId) { + const context = await requireClientContext(actorUid); + if (context.tenant.tenantId !== tenantId || context.business.businessId !== businessId) { + throw new AppError('FORBIDDEN', 'Order is outside the current client context', 403, { orderId }); + } + + const result = await query( + ` + SELECT + o.id AS "orderId", + o.title AS "eventName", + o.description, + o.notes, + o.vendor_id AS "vendorId", + o.service_type AS "serviceType", + o.metadata, + COALESCE( + json_agg( + json_build_object( + 'shiftId', s.id, + 'clockPointId', s.clock_point_id, + 'date', to_char(s.starts_at AT TIME ZONE 'UTC', 'YYYY-MM-DD'), + 'startTime', to_char(s.starts_at AT TIME ZONE 'UTC', 'HH24:MI'), + 'endTime', to_char(s.ends_at AT TIME ZONE 'UTC', 'HH24:MI'), + 'roles', ( + SELECT json_agg( + json_build_object( + 'roleId', sr.role_id, + 'roleCode', sr.role_code, + 'roleName', sr.role_name, + 'workerCount', sr.workers_needed, + 'hourlyRateCents', sr.bill_rate_cents, + 'payRateCents', sr.pay_rate_cents, + 'billRateCents', sr.bill_rate_cents, + 'startTime', to_char(s.starts_at AT TIME ZONE 'UTC', 'HH24:MI'), + 'endTime', to_char(s.ends_at AT TIME ZONE 'UTC', 'HH24:MI') + ) + ORDER BY sr.role_name ASC + ) + FROM shift_roles sr + WHERE sr.shift_id = s.id + ) + ) + ORDER BY s.starts_at ASC + ), + '[]'::json + ) AS shifts + FROM orders o + JOIN shifts s ON s.order_id = o.id + WHERE o.id = $1 + AND o.tenant_id = $2 + AND o.business_id = $3 + GROUP BY o.id + `, + [orderId, tenantId, businessId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found for edit flow', 404, { orderId }); + } + + return result.rows[0]; +} + +async function resolveStaffAssignmentForClock(actorUid, tenantId, payload, { requireOpenSession = false } = {}) { + const context = await requireStaffContext(actorUid); + if (payload.assignmentId) { + return { assignmentId: payload.assignmentId, context }; + } + + if (payload.applicationId) { + const fromApplication = await query( + ` + SELECT a.id AS "assignmentId" + FROM assignments a + JOIN applications app ON app.id = a.application_id + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND app.id = $2 + AND s.user_id = $3 + ORDER BY a.created_at DESC + LIMIT 1 + `, + [tenantId, payload.applicationId, actorUid] + ); + if (fromApplication.rowCount > 0) { + return { assignmentId: fromApplication.rows[0].assignmentId, context }; + } + } + + if (payload.shiftId) { + const fromShift = await query( + ` + SELECT a.id AS "assignmentId" + FROM assignments a + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND a.shift_id = $2 + AND s.user_id = $3 + ORDER BY a.created_at DESC + LIMIT 1 + `, + [tenantId, payload.shiftId, actorUid] + ); + if (fromShift.rowCount > 0) { + return { assignmentId: fromShift.rows[0].assignmentId, context }; + } + } + + if (requireOpenSession) { + const openSession = await query( + ` + SELECT attendance_sessions.assignment_id AS "assignmentId" + FROM attendance_sessions + JOIN staffs s ON s.id = attendance_sessions.staff_id + WHERE attendance_sessions.tenant_id = $1 + AND s.user_id = $2 + AND attendance_sessions.status = 'OPEN' + ORDER BY attendance_sessions.updated_at DESC + LIMIT 1 + `, + [tenantId, actorUid] + ); + if (openSession.rowCount > 0) { + return { assignmentId: openSession.rows[0].assignmentId, context }; + } + } + + throw new AppError('NOT_FOUND', 'No assignment found for the current staff clock action', 404, payload); +} + +async function loadAssignmentMonitoringContext(client, tenantId, assignmentId, actorUid) { + const result = await client.query( + ` + SELECT + a.id, + a.tenant_id, + a.business_id, + a.vendor_id, + a.shift_id, + a.shift_role_id, + a.staff_id, + a.status, + s.clock_point_id, + s.title AS shift_title, + s.starts_at, + s.ends_at, + s.clock_in_mode, + s.allow_clock_in_override, + cp.default_clock_in_mode, + cp.allow_clock_in_override AS default_allow_clock_in_override, + cp.nfc_tag_uid AS expected_nfc_tag_uid, + COALESCE(s.latitude, cp.latitude) AS expected_latitude, + COALESCE(s.longitude, cp.longitude) AS expected_longitude, + COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS geofence_radius_meters + FROM assignments a + JOIN staffs st ON st.id = a.staff_id + JOIN shifts s ON s.id = a.shift_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.id = $2 + AND st.user_id = $3 + FOR UPDATE OF a, s + `, + [tenantId, assignmentId, actorUid] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Assignment not found in staff scope', 404, { + assignmentId, + }); + } + + return result.rows[0]; +} + +async function ensureActorUser(client, actor, fields = {}) { + await client.query( + ` + INSERT INTO users (id, email, display_name, phone, status, metadata) + VALUES ($1, $2, $3, $4, 'ACTIVE', COALESCE($5::jsonb, '{}'::jsonb)) + ON CONFLICT (id) DO UPDATE + SET email = COALESCE(EXCLUDED.email, users.email), + display_name = COALESCE(EXCLUDED.display_name, users.display_name), + phone = COALESCE(EXCLUDED.phone, users.phone), + metadata = COALESCE(users.metadata, '{}'::jsonb) || COALESCE(EXCLUDED.metadata, '{}'::jsonb), + updated_at = NOW() + `, + [ + actor.uid, + fields.email ?? actor.email ?? null, + fields.displayName ?? actor.email ?? null, + fields.phone ?? null, + JSON.stringify(fields.metadata || {}), + ] + ); +} + +async function insertDomainEvent(client, { + tenantId, + aggregateType, + aggregateId, + eventType, + actorUserId, + payload, +}) { + await client.query( + ` + INSERT INTO domain_events ( + tenant_id, + aggregate_type, + aggregate_id, + sequence, + event_type, + actor_user_id, + payload + ) + SELECT + $1, + $2, + $3, + COALESCE(MAX(sequence) + 1, 1), + $4, + $5, + $6::jsonb + FROM domain_events + WHERE tenant_id = $1 + AND aggregate_type = $2 + AND aggregate_id = $3 + `, + [tenantId, aggregateType, aggregateId, eventType, actorUserId, JSON.stringify(payload || {})] + ); +} + +async function requireBusinessMembership(client, businessId, userId) { + const result = await client.query( + ` + SELECT bm.id, bm.tenant_id, bm.business_id, bm.user_id + FROM business_memberships bm + WHERE bm.business_id = $1 + AND bm.user_id = $2 + AND bm.membership_status = 'ACTIVE' + `, + [businessId, userId] + ); + if (result.rowCount === 0) { + throw new AppError('FORBIDDEN', 'Business membership not found for current user', 403, { + businessId, + userId, + }); + } + return result.rows[0]; +} + +async function requireClockPoint(client, tenantId, businessId, hubId, { forUpdate = false } = {}) { + const result = await client.query( + ` + SELECT + id, + tenant_id, + business_id, + label, + status, + cost_center_id, + nfc_tag_uid, + latitude, + longitude, + geofence_radius_meters, + default_clock_in_mode, + allow_clock_in_override, + metadata + FROM clock_points + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + ${forUpdate ? 'FOR UPDATE' : ''} + `, + [hubId, tenantId, businessId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Hub not found in business scope', 404, { + tenantId, + businessId, + hubId, + }); + } + return result.rows[0]; +} + +async function requireInvoice(client, tenantId, businessId, invoiceId) { + const result = await client.query( + ` + SELECT id, tenant_id, business_id, status, metadata + FROM invoices + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + FOR UPDATE + `, + [invoiceId, tenantId, businessId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Invoice not found in business scope', 404, { + tenantId, + businessId, + invoiceId, + }); + } + return result.rows[0]; +} + +async function requireStaffByActor(client, tenantId, actorUid) { + const result = await client.query( + ` + SELECT s.id, s.tenant_id, s.user_id, s.full_name, s.email, s.phone, s.metadata, s.primary_role, + w.id AS workforce_id, w.vendor_id, w.workforce_number + FROM staffs s + LEFT JOIN workforce w ON w.staff_id = s.id + WHERE s.tenant_id = $1 + AND s.user_id = $2 + ORDER BY s.created_at ASC + LIMIT 1 + FOR UPDATE OF s + `, + [tenantId, actorUid] + ); + if (result.rowCount === 0) { + throw new AppError('FORBIDDEN', 'Staff profile not found for current user', 403, { + tenantId, + actorUid, + }); + } + return result.rows[0]; +} + +async function requireShiftRoleForStaffApply(client, tenantId, shiftId, roleId, staffId) { + const result = await client.query( + ` + SELECT + s.id AS shift_id, + s.tenant_id, + s.business_id, + s.vendor_id, + s.status AS shift_status, + s.starts_at, + s.ends_at, + sr.id AS shift_role_id, + sr.role_code, + sr.role_name, + sr.workers_needed, + sr.assigned_count, + sr.pay_rate_cents + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.id = $1 + AND s.tenant_id = $2 + AND ($3::uuid IS NULL OR sr.id = $3) + AND s.status IN ('OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED') + AND NOT EXISTS ( + SELECT 1 + FROM applications a + WHERE a.shift_role_id = sr.id + AND a.staff_id = $4 + AND a.status IN ('PENDING', 'CONFIRMED', 'CHECKED_IN', 'COMPLETED') + ) + ORDER BY sr.created_at ASC + LIMIT 1 + FOR UPDATE OF s, sr + `, + [shiftId, tenantId, roleId || null, staffId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Open shift role not found or already applied', 404, { + tenantId, + shiftId, + roleId: roleId || null, + }); + } + return result.rows[0]; +} + +async function requirePendingAssignmentForActor(client, tenantId, shiftId, actorUid) { + const result = await client.query( + ` + SELECT + a.id, + a.tenant_id, + a.business_id, + a.vendor_id, + a.shift_id, + a.shift_role_id, + a.workforce_id, + a.staff_id, + a.status, + a.metadata + FROM assignments a + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND a.shift_id = $2 + AND s.user_id = $3 + AND a.status = 'ASSIGNED' + ORDER BY a.created_at ASC + LIMIT 1 + FOR UPDATE OF a + `, + [tenantId, shiftId, actorUid] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Pending shift assignment not found for current user', 404, { + tenantId, + shiftId, + actorUid, + }); + } + return result.rows[0]; +} + +async function requireAnyAssignmentForActor(client, tenantId, shiftId, actorUid) { + const result = await client.query( + ` + SELECT + a.id, + a.tenant_id, + a.business_id, + a.vendor_id, + a.shift_id, + a.shift_role_id, + a.workforce_id, + a.staff_id, + a.status, + a.metadata + FROM assignments a + JOIN staffs s ON s.id = a.staff_id + WHERE a.tenant_id = $1 + AND a.shift_id = $2 + AND s.user_id = $3 + ORDER BY a.created_at ASC + LIMIT 1 + FOR UPDATE OF a + `, + [tenantId, shiftId, actorUid] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Shift assignment not found for current user', 404, { + tenantId, + shiftId, + actorUid, + }); + } + return result.rows[0]; +} + +async function refreshShiftRoleCounts(client, shiftRoleId) { + await client.query( + ` + UPDATE shift_roles sr + SET assigned_count = counts.assigned_count, + updated_at = NOW() + FROM ( + SELECT $1::uuid AS shift_role_id, + COUNT(*) FILTER ( + WHERE status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + )::INTEGER AS assigned_count + FROM assignments + WHERE shift_role_id = $1 + ) counts + WHERE sr.id = counts.shift_role_id + `, + [shiftRoleId] + ); +} + +async function refreshShiftCounts(client, shiftId) { + await client.query( + ` + UPDATE shifts s + SET assigned_workers = counts.assigned_workers, + updated_at = NOW() + FROM ( + SELECT $1::uuid AS shift_id, + COUNT(*) FILTER ( + WHERE status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + )::INTEGER AS assigned_workers + FROM assignments + WHERE shift_id = $1 + ) counts + WHERE s.id = counts.shift_id + `, + [shiftId] + ); +} + +async function resolveStaffOnboardingScope(client, actorUid, tenantId, vendorId) { + const existing = await loadActorContext(actorUid); + if (existing.tenant?.tenantId && existing.vendor?.vendorId) { + return { + tenantId: existing.tenant.tenantId, + vendorId: existing.vendor.vendorId, + }; + } + + if (tenantId && vendorId) { + const verify = await client.query( + ` + SELECT t.id AS tenant_id, v.id AS vendor_id + FROM tenants t + JOIN vendors v ON v.tenant_id = t.id + WHERE t.id = $1 + AND v.id = $2 + AND t.status = 'ACTIVE' + AND v.status = 'ACTIVE' + `, + [tenantId, vendorId] + ); + if (verify.rowCount === 0) { + throw new AppError('VALIDATION_ERROR', 'tenantId and vendorId do not resolve to an active onboarding scope', 400, { + tenantId, + vendorId, + }); + } + return { + tenantId, + vendorId, + }; + } + + const fallback = await client.query( + ` + SELECT t.id AS tenant_id, v.id AS vendor_id + FROM tenants t + JOIN vendors v ON v.tenant_id = t.id + WHERE t.status = 'ACTIVE' + AND v.status = 'ACTIVE' + ORDER BY t.created_at ASC, v.created_at ASC + LIMIT 1 + ` + ); + if (fallback.rowCount === 0) { + throw new AppError('CONFIGURATION_ERROR', 'No active tenant/vendor onboarding scope is configured', 500); + } + + return { + tenantId: fallback.rows[0].tenant_id, + vendorId: fallback.rows[0].vendor_id, + }; +} + +function buildStaffMetadataPatch(existing, payload) { + return { + ...existing, + ...(payload.bio !== undefined ? { bio: payload.bio } : {}), + ...(payload.firstName !== undefined ? { firstName: payload.firstName } : {}), + ...(payload.lastName !== undefined ? { lastName: payload.lastName } : {}), + ...(payload.preferredLocations !== undefined ? { preferredLocations: payload.preferredLocations } : {}), + ...(payload.maxDistanceMiles !== undefined ? { maxDistanceMiles: payload.maxDistanceMiles } : {}), + ...(payload.industries !== undefined ? { industries: payload.industries } : {}), + ...(payload.skills !== undefined ? { skills: payload.skills } : {}), + ...(payload.profileVisible !== undefined ? { profileVisible: payload.profileVisible } : {}), + }; +} + +export async function createHub(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const businessMembership = await requireBusinessMembership(client, context.business.businessId, actor.uid); + + let costCenterId = payload.costCenterId || null; + if (costCenterId) { + const costCenter = await client.query( + ` + SELECT id + FROM cost_centers + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + `, + [costCenterId, context.tenant.tenantId, context.business.businessId] + ); + if (costCenter.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Cost center not found in business scope', 404, { + costCenterId, + }); + } + } + + const result = await client.query( + ` + INSERT INTO clock_points ( + tenant_id, + business_id, + label, + address, + latitude, + longitude, + geofence_radius_meters, + nfc_tag_uid, + default_clock_in_mode, + allow_clock_in_override, + cost_center_id, + status, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7, 120), $8, COALESCE($9, 'EITHER'), COALESCE($10, TRUE), $11, 'ACTIVE', $12::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + context.business.businessId, + payload.name, + payload.fullAddress || null, + payload.latitude ?? null, + payload.longitude ?? null, + payload.geofenceRadiusMeters ?? null, + payload.nfcTagId || null, + payload.clockInMode || null, + payload.allowClockInOverride ?? null, + costCenterId, + JSON.stringify({ + placeId: payload.placeId || null, + street: payload.street || null, + city: payload.city || null, + state: payload.state || null, + country: payload.country || null, + zipCode: payload.zipCode || null, + clockInPolicyConfiguredBy: businessMembership.id, + createdByMembershipId: businessMembership.id, + }), + ] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: result.rows[0].id, + eventType: 'HUB_CREATED', + actorUserId: actor.uid, + payload, + }); + + return { hubId: result.rows[0].id, created: true }; + }); +} + +export async function updateHub(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + + let costCenterId = payload.costCenterId; + if (costCenterId) { + const costCenter = await client.query( + ` + SELECT id + FROM cost_centers + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + `, + [costCenterId, context.tenant.tenantId, context.business.businessId] + ); + if (costCenter.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Cost center not found in business scope', 404, { + costCenterId, + }); + } + } + + const nextMetadata = { + ...(hub.metadata || {}), + ...(payload.placeId !== undefined ? { placeId: payload.placeId } : {}), + ...(payload.street !== undefined ? { street: payload.street } : {}), + ...(payload.city !== undefined ? { city: payload.city } : {}), + ...(payload.state !== undefined ? { state: payload.state } : {}), + ...(payload.country !== undefined ? { country: payload.country } : {}), + ...(payload.zipCode !== undefined ? { zipCode: payload.zipCode } : {}), + }; + + await client.query( + ` + UPDATE clock_points + SET label = COALESCE($2, label), + address = COALESCE($3, address), + latitude = COALESCE($4, latitude), + longitude = COALESCE($5, longitude), + geofence_radius_meters = COALESCE($6, geofence_radius_meters), + cost_center_id = COALESCE($7, cost_center_id), + default_clock_in_mode = COALESCE($8, default_clock_in_mode), + allow_clock_in_override = COALESCE($9, allow_clock_in_override), + metadata = $10::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [ + hub.id, + payload.name || null, + payload.fullAddress || null, + payload.latitude ?? null, + payload.longitude ?? null, + payload.geofenceRadiusMeters ?? null, + costCenterId || null, + payload.clockInMode || null, + payload.allowClockInOverride ?? null, + JSON.stringify(nextMetadata), + ] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_UPDATED', + actorUserId: actor.uid, + payload, + }); + + return { hubId: hub.id, updated: true }; + }); +} + +export async function deleteHub(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + const activeOrders = await client.query( + ` + SELECT 1 + FROM shifts + WHERE clock_point_id = $1 + AND status IN ('DRAFT', 'OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE') + LIMIT 1 + `, + [hub.id] + ); + if (activeOrders.rowCount > 0) { + throw new AppError('HUB_DELETE_BLOCKED', 'Cannot delete a hub with active orders or shifts', 409, { + hubId: hub.id, + }); + } + + await client.query( + ` + UPDATE clock_points + SET status = 'INACTIVE', + updated_at = NOW() + WHERE id = $1 + `, + [hub.id] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_ARCHIVED', + actorUserId: actor.uid, + payload: { reason: payload.reason || null }, + }); + + return { hubId: hub.id, deleted: true }; + }); +} + +export async function assignHubNfc(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + await client.query( + ` + UPDATE clock_points + SET nfc_tag_uid = $2, + updated_at = NOW() + WHERE id = $1 + `, + [hub.id, payload.nfcTagId] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_NFC_ASSIGNED', + actorUserId: actor.uid, + payload, + }); + return { hubId: hub.id, nfcTagId: payload.nfcTagId }; + }); +} + +export async function assignHubManager(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const hub = await requireClockPoint(client, context.tenant.tenantId, context.business.businessId, payload.hubId, { forUpdate: true }); + let businessMembershipId = payload.businessMembershipId || null; + if (!businessMembershipId) { + const membership = await client.query( + ` + SELECT id + FROM business_memberships + WHERE tenant_id = $1 + AND business_id = $2 + AND user_id = $3 + AND membership_status = 'ACTIVE' + `, + [context.tenant.tenantId, context.business.businessId, payload.managerUserId] + ); + if (membership.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Business team member not found for hub manager assignment', 404, { + managerUserId: payload.managerUserId, + }); + } + businessMembershipId = membership.rows[0].id; + } else { + const membership = await client.query( + ` + SELECT id + FROM business_memberships + WHERE id = $1 + AND tenant_id = $2 + AND business_id = $3 + AND membership_status = 'ACTIVE' + `, + [businessMembershipId, context.tenant.tenantId, context.business.businessId] + ); + if (membership.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Business membership not found for hub manager assignment', 404, { + businessMembershipId, + }); + } + } + + const result = await client.query( + ` + INSERT INTO hub_managers (tenant_id, hub_id, business_membership_id) + VALUES ($1, $2, $3) + ON CONFLICT (hub_id, business_membership_id) DO UPDATE + SET updated_at = NOW() + RETURNING id + `, + [context.tenant.tenantId, hub.id, businessMembershipId] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'clock_point', + aggregateId: hub.id, + eventType: 'HUB_MANAGER_ASSIGNED', + actorUserId: actor.uid, + payload: { + businessMembershipId, + }, + }); + + return { managerAssignmentId: result.rows[0].id, hubId: hub.id, businessMembershipId }; + }); +} + +export async function approveInvoice(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const invoice = await requireInvoice(client, context.tenant.tenantId, context.business.businessId, payload.invoiceId); + await client.query( + ` + UPDATE invoices + SET status = 'APPROVED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [invoice.id, JSON.stringify({ + approvedBy: actor.uid, + approvedAt: new Date().toISOString(), + })] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'invoice', + aggregateId: invoice.id, + eventType: 'INVOICE_APPROVED', + actorUserId: actor.uid, + payload: { invoiceId: invoice.id }, + }); + return { invoiceId: invoice.id, status: 'APPROVED' }; + }); +} + +export async function disputeInvoice(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const invoice = await requireInvoice(client, context.tenant.tenantId, context.business.businessId, payload.invoiceId); + await client.query( + ` + UPDATE invoices + SET status = 'DISPUTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [invoice.id, JSON.stringify({ + disputedBy: actor.uid, + disputedAt: new Date().toISOString(), + disputeReason: payload.reason, + })] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'invoice', + aggregateId: invoice.id, + eventType: 'INVOICE_DISPUTED', + actorUserId: actor.uid, + payload: { invoiceId: invoice.id, reason: payload.reason }, + }); + return { invoiceId: invoice.id, status: 'DISPUTED', reason: payload.reason }; + }); +} + +export async function rateWorkerFromCoverage(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignmentResult = await client.query( + ` + SELECT a.id, a.tenant_id, a.business_id, a.staff_id + FROM assignments a + WHERE a.tenant_id = $1 + AND a.business_id = $2 + AND a.staff_id = $3 + AND ($4::uuid IS NULL OR a.id = $4) + ORDER BY a.updated_at DESC + LIMIT 1 + FOR UPDATE OF a + `, + [context.tenant.tenantId, context.business.businessId, payload.staffId, payload.assignmentId || null] + ); + if (assignmentResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Assignment not found for worker review in business scope', 404, payload); + } + const assignment = assignmentResult.rows[0]; + + const reviewResult = await client.query( + ` + INSERT INTO staff_reviews ( + tenant_id, + business_id, + staff_id, + assignment_id, + reviewer_user_id, + rating, + review_text, + tags + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb) + ON CONFLICT (business_id, assignment_id, staff_id) DO UPDATE + SET reviewer_user_id = EXCLUDED.reviewer_user_id, + rating = EXCLUDED.rating, + review_text = EXCLUDED.review_text, + tags = EXCLUDED.tags, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + context.business.businessId, + payload.staffId, + assignment.id, + actor.uid, + payload.rating, + payload.feedback || null, + JSON.stringify(ensureArray(payload.issueFlags || [])), + ] + ); + + if (payload.markAsFavorite === true) { + await client.query( + ` + INSERT INTO staff_favorites (tenant_id, business_id, staff_id, created_by_user_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (business_id, staff_id) DO UPDATE + SET created_by_user_id = EXCLUDED.created_by_user_id + `, + [context.tenant.tenantId, context.business.businessId, payload.staffId, actor.uid] + ); + } + + if (payload.markAsFavorite === false) { + await client.query( + ` + DELETE FROM staff_favorites + WHERE tenant_id = $1 + AND business_id = $2 + AND staff_id = $3 + `, + [context.tenant.tenantId, context.business.businessId, payload.staffId] + ); + } + + await client.query( + ` + UPDATE staffs + SET average_rating = review_stats.avg_rating, + rating_count = review_stats.rating_count, + updated_at = NOW() + FROM ( + SELECT staff_id, + ROUND(AVG(rating)::numeric, 2) AS avg_rating, + COUNT(*)::INTEGER AS rating_count + FROM staff_reviews + WHERE staff_id = $1 + GROUP BY staff_id + ) review_stats + WHERE staffs.id = review_stats.staff_id + `, + [payload.staffId] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_review', + aggregateId: reviewResult.rows[0].id, + eventType: 'STAFF_REVIEWED_FROM_COVERAGE', + actorUserId: actor.uid, + payload, + }); + + return { + reviewId: reviewResult.rows[0].id, + assignmentId: assignment.id, + staffId: payload.staffId, + rating: payload.rating, + markAsFavorite: payload.markAsFavorite ?? null, + issueFlags: ensureArray(payload.issueFlags || []), + feedback: payload.feedback || null, + }; + }); +} + +export async function cancelLateWorker(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const result = await client.query( + ` + SELECT + a.id, + a.shift_id, + a.shift_role_id, + a.staff_id, + a.status, + s.required_workers, + s.assigned_workers, + s.tenant_id, + s.clock_point_id, + s.starts_at, + s.title AS shift_title, + st.user_id AS "staffUserId" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + JOIN staffs st ON st.id = a.staff_id + WHERE a.id = $1 + AND a.tenant_id = $2 + AND a.business_id = $3 + FOR UPDATE OF a, s + `, + [payload.assignmentId, context.tenant.tenantId, context.business.businessId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Late worker assignment not found in business scope', 404, { + assignmentId: payload.assignmentId, + }); + } + const assignment = result.rows[0]; + if (['CHECKED_IN', 'CHECKED_OUT', 'COMPLETED'].includes(assignment.status)) { + throw new AppError('LATE_WORKER_CANCEL_BLOCKED', 'Worker is already checked in or completed and cannot be cancelled as late', 409, { + assignmentId: assignment.id, + }); + } + + const hasRecentIncident = await client.query( + ` + SELECT 1 + FROM geofence_incidents + WHERE assignment_id = $1 + AND incident_type IN ('OUTSIDE_GEOFENCE', 'LOCATION_UNAVAILABLE', 'CLOCK_IN_OVERRIDE') + AND occurred_at >= $2::timestamptz - INTERVAL '30 minutes' + LIMIT 1 + `, + [assignment.id, assignment.starts_at] + ); + const shiftStartTime = assignment.starts_at ? new Date(assignment.starts_at).getTime() : null; + const startGraceElapsed = shiftStartTime != null + ? Date.now() >= shiftStartTime + (10 * 60 * 1000) + : false; + + if (!startGraceElapsed && hasRecentIncident.rowCount === 0) { + throw new AppError('LATE_WORKER_NOT_CONFIRMED', 'Late worker cancellation requires either a geofence incident or a started shift window', 409, { + assignmentId: assignment.id, + }); + } + + await client.query( + ` + UPDATE assignments + SET status = 'CANCELLED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ + cancellationReason: payload.reason || 'Cancelled for lateness', + cancelledBy: actor.uid, + cancelledAt: new Date().toISOString(), + })] + ); + await refreshShiftRoleCounts(client, assignment.shift_role_id); + await refreshShiftCounts(client, assignment.shift_id); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'LATE_WORKER_CANCELLED', + actorUserId: actor.uid, + payload, + }); + + await enqueueHubManagerAlert(client, { + tenantId: context.tenant.tenantId, + businessId: context.business.businessId, + shiftId: assignment.shift_id, + assignmentId: assignment.id, + hubId: assignment.clock_point_id, + notificationType: 'LATE_WORKER_CANCELLED', + priority: 'HIGH', + subject: 'Late worker was removed from shift', + body: `${assignment.shift_title}: a late worker was cancelled and replacement search should begin`, + payload: { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + reason: payload.reason || 'Cancelled for lateness', + }, + dedupeScope: assignment.id, + }); + + await enqueueUserAlert(client, { + tenantId: context.tenant.tenantId, + businessId: context.business.businessId, + shiftId: assignment.shift_id, + assignmentId: assignment.id, + recipientUserId: assignment.staffUserId, + notificationType: 'SHIFT_ASSIGNMENT_CANCELLED_LATE', + priority: 'HIGH', + subject: 'Shift assignment cancelled', + body: `${assignment.shift_title}: your assignment was cancelled because you were marked late`, + payload: { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + reason: payload.reason || 'Cancelled for lateness', + }, + dedupeScope: assignment.id, + }); + + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + replacementSearchTriggered: true, + status: 'CANCELLED', + }; + }); +} + +export async function createClientOneTimeOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + const commandPayload = await buildOrderCreatePayloadFromMobile(actor, context, payload, 'ONE_TIME'); + return createOrderCommand(actor, commandPayload); +} + +export async function createClientRecurringOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + const commandPayload = await buildOrderCreatePayloadFromMobile(actor, context, payload, 'RECURRING'); + return createOrderCommand(actor, commandPayload); +} + +export async function createClientPermanentOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + const commandPayload = await buildOrderCreatePayloadFromMobile(actor, context, payload, 'PERMANENT'); + return createOrderCommand(actor, commandPayload); +} + +export async function createEditedOrderCopy(actor, payload) { + const context = await requireClientContext(actor.uid); + const template = await loadEditableOrderTemplate( + actor.uid, + context.tenant.tenantId, + context.business.businessId, + payload.orderId + ); + + const templateShifts = Array.isArray(template.shifts) ? template.shifts : []; + const templatePositions = templateShifts.flatMap((shift) => (Array.isArray(shift.roles) ? shift.roles : []).map((role) => ({ + ...role, + startTime: role.startTime || shift.startTime, + endTime: role.endTime || shift.endTime, + }))); + const firstShift = templateShifts[0] || {}; + const lastShift = templateShifts[templateShifts.length - 1] || {}; + const inferredOrderType = payload.orderType || template.metadata?.orderType || 'ONE_TIME'; + const inferredDays = [...new Set(templateShifts.map((shift) => new Date(`${shift.date}T00:00:00.000Z`).getUTCDay()).filter((value) => Number.isInteger(value)))]; + + const commandPayload = await buildOrderCreatePayloadFromMobile( + actor, + context, + { + ...payload, + hubId: payload.hubId || firstShift.clockPointId, + vendorId: payload.vendorId ?? template.vendorId ?? undefined, + eventName: payload.eventName || template.eventName, + description: payload.description ?? template.description ?? undefined, + notes: payload.notes ?? template.notes ?? undefined, + serviceType: payload.serviceType ?? template.serviceType ?? undefined, + positions: payload.positions || templatePositions, + orderDate: payload.orderDate || firstShift.date, + startDate: payload.startDate || firstShift.date, + endDate: payload.endDate || lastShift.date || firstShift.date, + recurrenceDays: payload.recurrenceDays || inferredDays, + daysOfWeek: payload.daysOfWeek || inferredDays, + metadata: { + sourceOrderId: payload.orderId, + ...template.metadata, + ...payload.metadata, + }, + }, + inferredOrderType, + { + metadata: { + editSourceOrderId: payload.orderId, + }, + } + ); + + return createOrderCommand(actor, commandPayload); +} + +export async function cancelClientOrder(actor, payload) { + const context = await requireClientContext(actor.uid); + return cancelOrderCommand(actor, { + tenantId: context.tenant.tenantId, + orderId: payload.orderId, + reason: payload.reason, + metadata: payload.metadata, + }); +} + +export async function staffClockIn(actor, payload) { + const context = await requireStaffContext(actor.uid); + const { assignmentId } = await resolveStaffAssignmentForClock(actor.uid, context.tenant.tenantId, payload); + return clockInCommand(actor, { + assignmentId, + sourceType: inferAttendanceSourceType(payload), + sourceReference: payload.sourceReference || payload.notes || null, + nfcTagUid: payload.nfcTagId || null, + deviceId: payload.deviceId || null, + latitude: payload.latitude, + longitude: payload.longitude, + accuracyMeters: payload.accuracyMeters, + capturedAt: payload.capturedAt, + overrideReason: payload.overrideReason || null, + proofNonce: payload.proofNonce || null, + proofTimestamp: payload.proofTimestamp || null, + attestationProvider: payload.attestationProvider || null, + attestationToken: payload.attestationToken || null, + rawPayload: { + notes: payload.notes || null, + isMockLocation: payload.isMockLocation ?? null, + ...(payload.rawPayload || {}), + }, + }); +} + +export async function staffClockOut(actor, payload) { + const context = await requireStaffContext(actor.uid); + const { assignmentId } = await resolveStaffAssignmentForClock( + actor.uid, + context.tenant.tenantId, + payload, + { requireOpenSession: true } + ); + return clockOutCommand(actor, { + assignmentId, + sourceType: inferAttendanceSourceType(payload), + sourceReference: payload.sourceReference || payload.notes || null, + nfcTagUid: payload.nfcTagId || null, + deviceId: payload.deviceId || null, + latitude: payload.latitude, + longitude: payload.longitude, + accuracyMeters: payload.accuracyMeters, + capturedAt: payload.capturedAt, + overrideReason: payload.overrideReason || null, + proofNonce: payload.proofNonce || null, + proofTimestamp: payload.proofTimestamp || null, + attestationProvider: payload.attestationProvider || null, + attestationToken: payload.attestationToken || null, + rawPayload: { + notes: payload.notes || null, + breakMinutes: payload.breakMinutes ?? null, + applicationId: payload.applicationId || null, + isMockLocation: payload.isMockLocation ?? null, + ...(payload.rawPayload || {}), + }, + }); +} + +export async function registerClientPushToken(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const token = await registerPushToken(client, { + tenantId: context.tenant.tenantId, + userId: actor.uid, + businessMembershipId: context.business.membershipId, + provider: payload.provider, + platform: payload.platform, + pushToken: payload.pushToken, + deviceId: payload.deviceId || null, + appVersion: payload.appVersion || null, + appBuild: payload.appBuild || null, + locale: payload.locale || null, + timezone: payload.timezone || null, + notificationsEnabled: payload.notificationsEnabled ?? true, + metadata: payload.metadata || {}, + }); + + return { + tokenId: token.id, + provider: token.provider, + platform: token.platform, + notificationsEnabled: token.notificationsEnabled, + }; + }); +} + +export async function unregisterClientPushToken(actor, payload) { + const context = await requireClientContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const removed = await unregisterPushToken(client, { + tenantId: context.tenant.tenantId, + userId: actor.uid, + tokenId: payload.tokenId || null, + pushToken: payload.pushToken || null, + reason: payload.reason || 'CLIENT_SIGN_OUT', + }); + + return { + removedCount: removed.length, + removed, + }; + }); +} + +export async function registerStaffPushToken(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const token = await registerPushToken(client, { + tenantId: context.tenant.tenantId, + userId: actor.uid, + staffId: context.staff.staffId, + provider: payload.provider, + platform: payload.platform, + pushToken: payload.pushToken, + deviceId: payload.deviceId || null, + appVersion: payload.appVersion || null, + appBuild: payload.appBuild || null, + locale: payload.locale || null, + timezone: payload.timezone || null, + notificationsEnabled: payload.notificationsEnabled ?? true, + metadata: payload.metadata || {}, + }); + + return { + tokenId: token.id, + provider: token.provider, + platform: token.platform, + notificationsEnabled: token.notificationsEnabled, + }; + }); +} + +export async function unregisterStaffPushToken(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const removed = await unregisterPushToken(client, { + tenantId: context.tenant.tenantId, + userId: actor.uid, + tokenId: payload.tokenId || null, + pushToken: payload.pushToken || null, + reason: payload.reason || 'STAFF_SIGN_OUT', + }); + + return { + removedCount: removed.length, + removed, + }; + }); +} + +function summarizeLocationPoints(points, assignment) { + let outOfGeofenceCount = 0; + let missingCoordinateCount = 0; + let maxDistance = null; + let latestOutsidePoint = null; + let latestMissingPoint = null; + + for (const point of points) { + if (point.latitude == null || point.longitude == null) { + missingCoordinateCount += 1; + latestMissingPoint = point; + continue; + } + + const distance = distanceMeters( + { + latitude: point.latitude, + longitude: point.longitude, + }, + { + latitude: assignment.expected_latitude, + longitude: assignment.expected_longitude, + } + ); + + if (distance != null) { + maxDistance = maxDistance == null ? distance : Math.max(maxDistance, distance); + if ( + assignment.geofence_radius_meters != null + && distance > assignment.geofence_radius_meters + ) { + outOfGeofenceCount += 1; + latestOutsidePoint = { ...point, distanceToClockPointMeters: distance }; + } + } + } + + return { + outOfGeofenceCount, + missingCoordinateCount, + maxDistanceToClockPointMeters: maxDistance, + latestOutsidePoint, + latestMissingPoint, + }; +} + +export async function submitLocationStreamBatch(actor, payload) { + const context = await requireStaffContext(actor.uid); + const { assignmentId } = await resolveStaffAssignmentForClock( + actor.uid, + context.tenant.tenantId, + payload, + { requireOpenSession: true } + ); + + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await loadAssignmentMonitoringContext( + client, + context.tenant.tenantId, + assignmentId, + actor.uid + ); + const policy = resolveEffectiveClockInPolicy(assignment); + const points = [...payload.points] + .map((point) => ({ + ...point, + capturedAt: toIsoOrNull(point.capturedAt), + })) + .sort((left, right) => new Date(left.capturedAt).getTime() - new Date(right.capturedAt).getTime()); + + const batchId = crypto.randomUUID(); + const summary = summarizeLocationPoints(points, assignment); + const objectUri = await uploadLocationBatch({ + tenantId: assignment.tenant_id, + staffId: assignment.staff_id, + assignmentId: assignment.id, + batchId, + payload: { + ...buildAssignmentReferencePayload(assignment), + effectiveClockInMode: policy.mode, + points, + metadata: payload.metadata || {}, + }, + }); + + await client.query( + ` + INSERT INTO location_stream_batches ( + id, + tenant_id, + business_id, + vendor_id, + shift_id, + assignment_id, + staff_id, + actor_user_id, + source_type, + device_id, + object_uri, + point_count, + out_of_geofence_count, + missing_coordinate_count, + max_distance_to_clock_point_meters, + started_at, + ended_at, + metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::timestamptz, $17::timestamptz, $18::jsonb + ) + `, + [ + batchId, + assignment.tenant_id, + assignment.business_id, + assignment.vendor_id, + assignment.shift_id, + assignment.id, + assignment.staff_id, + actor.uid, + payload.sourceType, + payload.deviceId || null, + objectUri, + points.length, + summary.outOfGeofenceCount, + summary.missingCoordinateCount, + summary.maxDistanceToClockPointMeters, + points[0]?.capturedAt || null, + points[points.length - 1]?.capturedAt || null, + JSON.stringify(payload.metadata || {}), + ] + ); + + const incidentIds = []; + if (summary.outOfGeofenceCount > 0) { + const incidentId = await recordGeofenceIncident(client, { + assignment, + actorUserId: actor.uid, + locationStreamBatchId: batchId, + incidentType: 'OUTSIDE_GEOFENCE', + severity: 'CRITICAL', + effectiveClockInMode: policy.mode, + sourceType: payload.sourceType, + deviceId: payload.deviceId || null, + latitude: summary.latestOutsidePoint?.latitude ?? null, + longitude: summary.latestOutsidePoint?.longitude ?? null, + accuracyMeters: summary.latestOutsidePoint?.accuracyMeters ?? null, + distanceToClockPointMeters: summary.latestOutsidePoint?.distanceToClockPointMeters ?? null, + withinGeofence: false, + message: `${summary.outOfGeofenceCount} location points were outside the configured geofence`, + occurredAt: summary.latestOutsidePoint?.capturedAt || points[points.length - 1]?.capturedAt || null, + metadata: { + pointCount: points.length, + outOfGeofenceCount: summary.outOfGeofenceCount, + objectUri, + }, + }); + incidentIds.push(incidentId); + await enqueueHubManagerAlert(client, { + tenantId: assignment.tenant_id, + businessId: assignment.business_id, + shiftId: assignment.shift_id, + assignmentId: assignment.id, + hubId: assignment.clock_point_id, + relatedIncidentId: incidentId, + notificationType: 'GEOFENCE_BREACH_ALERT', + priority: 'CRITICAL', + subject: 'Worker left the workplace geofence', + body: `${assignment.shift_title}: location stream shows the worker outside the geofence`, + payload: { + ...buildAssignmentReferencePayload(assignment), + batchId, + objectUri, + outOfGeofenceCount: summary.outOfGeofenceCount, + }, + dedupeScope: batchId, + }); + } + + if (summary.missingCoordinateCount > 0) { + const incidentId = await recordGeofenceIncident(client, { + assignment, + actorUserId: actor.uid, + locationStreamBatchId: batchId, + incidentType: 'LOCATION_UNAVAILABLE', + severity: 'WARNING', + effectiveClockInMode: policy.mode, + sourceType: payload.sourceType, + deviceId: payload.deviceId || null, + message: `${summary.missingCoordinateCount} location points were missing coordinates`, + occurredAt: summary.latestMissingPoint?.capturedAt || points[points.length - 1]?.capturedAt || null, + metadata: { + pointCount: points.length, + missingCoordinateCount: summary.missingCoordinateCount, + objectUri, + }, + }); + incidentIds.push(incidentId); + await enqueueHubManagerAlert(client, { + tenantId: assignment.tenant_id, + businessId: assignment.business_id, + shiftId: assignment.shift_id, + assignmentId: assignment.id, + hubId: assignment.clock_point_id, + relatedIncidentId: incidentId, + notificationType: 'LOCATION_SIGNAL_WARNING', + priority: 'HIGH', + subject: 'Worker location signal unavailable', + body: `${assignment.shift_title}: background location tracking reported missing coordinates`, + payload: { + ...buildAssignmentReferencePayload(assignment), + batchId, + objectUri, + missingCoordinateCount: summary.missingCoordinateCount, + }, + dedupeScope: `${batchId}:missing`, + }); + } + + await insertDomainEvent(client, { + tenantId: assignment.tenant_id, + aggregateType: 'location_stream_batch', + aggregateId: batchId, + eventType: 'LOCATION_STREAM_BATCH_RECORDED', + actorUserId: actor.uid, + payload: { + ...buildAssignmentReferencePayload(assignment), + batchId, + objectUri, + pointCount: points.length, + outOfGeofenceCount: summary.outOfGeofenceCount, + missingCoordinateCount: summary.missingCoordinateCount, + }, + }); + + return { + batchId, + assignmentId: assignment.id, + shiftId: assignment.shift_id, + effectiveClockInMode: policy.mode, + pointCount: points.length, + outOfGeofenceCount: summary.outOfGeofenceCount, + missingCoordinateCount: summary.missingCoordinateCount, + objectUri, + incidentIds, + }; + }); +} + +export async function updateStaffAvailabilityDay(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const result = await client.query( + ` + INSERT INTO staff_availability ( + tenant_id, + staff_id, + day_of_week, + availability_status, + time_slots, + metadata + ) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (staff_id, day_of_week) DO UPDATE + SET availability_status = EXCLUDED.availability_status, + time_slots = EXCLUDED.time_slots, + metadata = COALESCE(staff_availability.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + payload.dayOfWeek, + payload.availabilityStatus, + JSON.stringify(payload.slots || []), + JSON.stringify(payload.metadata || {}), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_availability', + aggregateId: result.rows[0].id, + eventType: 'STAFF_AVAILABILITY_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { + availabilityId: result.rows[0].id, + dayOfWeek: payload.dayOfWeek, + availabilityStatus: payload.availabilityStatus, + slots: payload.slots || [], + }; + }); +} + +export async function quickSetStaffAvailability(actor, payload) { + const context = await requireStaffContext(actor.uid); + const presets = { + all: [0, 1, 2, 3, 4, 5, 6], + weekdays: [1, 2, 3, 4, 5], + weekends: [0, 6], + clear: [], + }; + const selectedDays = presets[payload.quickSetType]; + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + + for (let day = 0; day <= 6; day += 1) { + const active = selectedDays.includes(day); + await client.query( + ` + INSERT INTO staff_availability ( + tenant_id, + staff_id, + day_of_week, + availability_status, + time_slots, + metadata + ) + VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb) + ON CONFLICT (staff_id, day_of_week) DO UPDATE + SET availability_status = EXCLUDED.availability_status, + time_slots = EXCLUDED.time_slots, + metadata = COALESCE(staff_availability.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + `, + [ + context.tenant.tenantId, + staff.id, + day, + active ? 'AVAILABLE' : 'UNAVAILABLE', + JSON.stringify(active ? payload.slots || [{ start: '08:00', end: '18:00' }] : []), + JSON.stringify({ + quickSetType: payload.quickSetType, + startDate: payload.startDate, + endDate: payload.endDate, + }), + ] + ); + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_AVAILABILITY_QUICK_SET', + actorUserId: actor.uid, + payload, + }); + return { + quickSetType: payload.quickSetType, + appliedDays: selectedDays, + }; + }); +} + +export async function applyForShift(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const shiftRole = await requireShiftRoleForStaffApply(client, context.tenant.tenantId, payload.shiftId, payload.roleId, staff.id); + const existingAssignment = await client.query( + ` + SELECT id + FROM assignments + WHERE shift_role_id = $1 + AND staff_id = $2 + AND status IN ('ASSIGNED', 'ACCEPTED', 'SWAP_REQUESTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + LIMIT 1 + `, + [shiftRole.shift_role_id, staff.id] + ); + if (existingAssignment.rowCount > 0) { + throw new AppError('CONFLICT', 'Staff is already assigned to this shift', 409, { + shiftId: payload.shiftId, + staffId: staff.id, + }); + } + + const instantBook = payload.instantBook === true && shiftRole.assigned_count < shiftRole.workers_needed && Boolean(staff.workforce_id); + const applicationResult = await client.query( + ` + INSERT INTO applications ( + tenant_id, + shift_id, + shift_role_id, + staff_id, + status, + origin, + metadata + ) + VALUES ($1, $2, $3, $4, $5, 'STAFF', $6::jsonb) + ON CONFLICT (shift_role_id, staff_id) DO UPDATE + SET status = EXCLUDED.status, + origin = EXCLUDED.origin, + metadata = COALESCE(applications.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id, status + `, + [ + context.tenant.tenantId, + shiftRole.shift_id, + shiftRole.shift_role_id, + staff.id, + instantBook ? 'CONFIRMED' : 'PENDING', + JSON.stringify({ + appliedBy: actor.uid, + instantBookRequested: payload.instantBook === true, + }), + ] + ); + + let assignmentId = null; + let assignmentStatus = null; + if (instantBook) { + const assignmentResult = await client.query( + ` + INSERT INTO assignments ( + tenant_id, + business_id, + vendor_id, + shift_id, + shift_role_id, + workforce_id, + staff_id, + application_id, + status, + assigned_at, + accepted_at, + metadata + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'ACCEPTED', NOW(), NOW(), $9::jsonb) + ON CONFLICT (shift_role_id, workforce_id) DO UPDATE + SET application_id = EXCLUDED.application_id, + status = 'ACCEPTED', + accepted_at = COALESCE(assignments.accepted_at, NOW()), + updated_at = NOW() + RETURNING id, status + `, + [ + context.tenant.tenantId, + shiftRole.business_id, + shiftRole.vendor_id, + shiftRole.shift_id, + shiftRole.shift_role_id, + staff.workforce_id, + staff.id, + applicationResult.rows[0].id, + JSON.stringify({ source: 'staff-apply-instant-book' }), + ] + ); + assignmentId = assignmentResult.rows[0].id; + assignmentStatus = assignmentResult.rows[0].status; + await refreshShiftRoleCounts(client, shiftRole.shift_role_id); + await refreshShiftCounts(client, shiftRole.shift_id); + } + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'application', + aggregateId: applicationResult.rows[0].id, + eventType: instantBook ? 'SHIFT_INSTANT_BOOKED' : 'SHIFT_APPLIED', + actorUserId: actor.uid, + payload, + }); + + return { + applicationId: applicationResult.rows[0].id, + shiftId: shiftRole.shift_id, + roleId: shiftRole.shift_role_id, + status: instantBook ? 'CONFIRMED' : applicationResult.rows[0].status, + assignmentId, + assignmentStatus, + }; + }); +} + +export async function acceptPendingShift(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await requirePendingAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid); + await client.query( + ` + UPDATE assignments + SET status = 'ACCEPTED', + accepted_at = COALESCE(accepted_at, NOW()), + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ acceptedBy: actor.uid })] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'STAFF_PENDING_SHIFT_ACCEPTED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + status: 'ACCEPTED', + }; + }); +} + +export async function declinePendingShift(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await requirePendingAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid); + await client.query( + ` + UPDATE assignments + SET status = 'CANCELLED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ + declinedBy: actor.uid, + declineReason: payload.reason || null, + })] + ); + await client.query( + ` + UPDATE applications + SET status = 'REJECTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE shift_role_id = $1 + AND staff_id = $3 + AND status IN ('PENDING', 'CONFIRMED') + `, + [assignment.shift_role_id, JSON.stringify({ rejectedBy: actor.uid, reason: payload.reason || null }), assignment.staff_id] + ); + await refreshShiftRoleCounts(client, assignment.shift_role_id); + await refreshShiftCounts(client, assignment.shift_id); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'STAFF_PENDING_SHIFT_DECLINED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + status: 'DECLINED', + }; + }); +} + +export async function requestShiftSwap(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const assignment = await requireAnyAssignmentForActor(client, context.tenant.tenantId, payload.shiftId, actor.uid); + if (!['ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT'].includes(assignment.status)) { + throw new AppError('INVALID_SWAP_STATE', 'Only accepted or worked shifts can be marked for swap', 409, { + shiftId: payload.shiftId, + assignmentStatus: assignment.status, + }); + } + await client.query( + ` + UPDATE assignments + SET status = 'SWAP_REQUESTED', + metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [assignment.id, JSON.stringify({ + swapRequestedAt: new Date().toISOString(), + swapReason: payload.reason || null, + })] + ); + await refreshShiftRoleCounts(client, assignment.shift_role_id); + await refreshShiftCounts(client, assignment.shift_id); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'assignment', + aggregateId: assignment.id, + eventType: 'SHIFT_SWAP_REQUESTED', + actorUserId: actor.uid, + payload, + }); + return { + assignmentId: assignment.id, + shiftId: assignment.shift_id, + status: 'SWAP_REQUESTED', + }; + }); +} + +export async function setupStaffProfile(actor, payload) { + return withTransaction(async (client) => { + const scope = await resolveStaffOnboardingScope(client, actor.uid, payload.tenantId, payload.vendorId); + await ensureActorUser(client, actor, { + email: payload.email ?? actor.email ?? null, + displayName: payload.fullName, + phone: payload.phoneNumber, + metadata: { source: 'staff-profile-setup' }, + }); + + await client.query( + ` + INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata) + VALUES ($1, $2, 'ACTIVE', 'member', '{"source":"staff-profile-setup"}'::jsonb) + ON CONFLICT (tenant_id, user_id) DO UPDATE + SET membership_status = 'ACTIVE', + updated_at = NOW() + `, + [scope.tenantId, actor.uid] + ); + + await client.query( + ` + INSERT INTO vendor_memberships (tenant_id, vendor_id, user_id, membership_status, vendor_role, metadata) + VALUES ($1, $2, $3, 'ACTIVE', 'member', '{"source":"staff-profile-setup"}'::jsonb) + ON CONFLICT (vendor_id, user_id) DO UPDATE + SET membership_status = 'ACTIVE', + updated_at = NOW() + `, + [scope.tenantId, scope.vendorId, actor.uid] + ); + + const fullName = payload.fullName.trim(); + const [firstName, ...lastParts] = fullName.split(/\s+/); + const lastName = lastParts.join(' '); + const metadata = { + bio: payload.bio || null, + firstName, + lastName, + preferredLocations: ensureArray(payload.preferredLocations || []), + maxDistanceMiles: payload.maxDistanceMiles ?? null, + industries: ensureArray(payload.industries || []), + skills: ensureArray(payload.skills || []), + }; + + const staffResult = await client.query( + ` + INSERT INTO staffs ( + tenant_id, + user_id, + full_name, + email, + phone, + status, + primary_role, + onboarding_status, + metadata + ) + VALUES ($1, $2, $3, $4, $5, 'ACTIVE', $6, 'COMPLETED', $7::jsonb) + ON CONFLICT (tenant_id, user_id) DO UPDATE + SET full_name = EXCLUDED.full_name, + email = COALESCE(EXCLUDED.email, staffs.email), + phone = COALESCE(EXCLUDED.phone, staffs.phone), + primary_role = COALESCE(EXCLUDED.primary_role, staffs.primary_role), + onboarding_status = 'COMPLETED', + metadata = COALESCE(staffs.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + scope.tenantId, + actor.uid, + fullName, + payload.email ?? actor.email ?? null, + normalizePhone(payload.phoneNumber), + payload.primaryRole || ensureArray(payload.skills || [])[0] || 'GENERAL_EVENT_STAFF', + JSON.stringify(metadata), + ] + ); + + const workforceResult = await client.query( + ` + INSERT INTO workforce ( + tenant_id, + vendor_id, + staff_id, + workforce_number, + employment_type, + status, + metadata + ) + VALUES ($1, $2, $3, $4, 'TEMP', 'ACTIVE', '{"source":"staff-profile-setup"}'::jsonb) + ON CONFLICT (vendor_id, staff_id) DO UPDATE + SET status = 'ACTIVE', + updated_at = NOW() + RETURNING id + `, + [ + scope.tenantId, + scope.vendorId, + staffResult.rows[0].id, + `WF-${normalizeSlug(firstName).toUpperCase()}-${crypto.randomUUID().slice(0, 8).toUpperCase()}`, + ] + ); + + await insertDomainEvent(client, { + tenantId: scope.tenantId, + aggregateType: 'staff', + aggregateId: staffResult.rows[0].id, + eventType: 'STAFF_PROFILE_SETUP_COMPLETED', + actorUserId: actor.uid, + payload, + }); + + return { + staffId: staffResult.rows[0].id, + workforceId: workforceResult.rows[0].id, + tenantId: scope.tenantId, + vendorId: scope.vendorId, + completed: true, + }; + }); +} + +export async function updatePersonalInfo(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor, { + email: payload.email ?? actor.email ?? null, + displayName: payload.displayName || null, + phone: payload.phone || null, + }); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const existingMetadata = staff.metadata || {}; + const nextMetadata = buildStaffMetadataPatch(existingMetadata, payload); + const fullName = [ + payload.firstName || existingMetadata.firstName || staff.full_name.split(' ')[0] || '', + payload.lastName || existingMetadata.lastName || staff.full_name.split(' ').slice(1).join(' ') || '', + ].filter(Boolean).join(' ').trim() || staff.full_name; + + await client.query( + ` + UPDATE staffs + SET full_name = $2, + email = COALESCE($3, email), + phone = COALESCE($4, phone), + metadata = $5::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [staff.id, fullName, payload.email ?? null, normalizePhone(payload.phone) ?? null, JSON.stringify(nextMetadata)] + ); + + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_PERSONAL_INFO_UPDATED', + actorUserId: actor.uid, + payload, + }); + + return { + staffId: staff.id, + fullName, + email: payload.email ?? staff.email ?? null, + phone: normalizePhone(payload.phone) ?? staff.phone ?? null, + metadata: nextMetadata, + }; + }); +} + +export async function updateProfileExperience(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const nextMetadata = buildStaffMetadataPatch(staff.metadata || {}, payload); + await client.query( + ` + UPDATE staffs + SET metadata = $2::jsonb, + primary_role = COALESCE($3, primary_role), + updated_at = NOW() + WHERE id = $1 + `, + [ + staff.id, + JSON.stringify(nextMetadata), + payload.primaryRole || null, + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_EXPERIENCE_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { + staffId: staff.id, + industries: ensureArray(nextMetadata.industries || []), + skills: ensureArray(nextMetadata.skills || []), + }; + }); +} + +export async function updatePreferredLocations(actor, payload) { + return updatePersonalInfo(actor, { + preferredLocations: payload.preferredLocations, + maxDistanceMiles: payload.maxDistanceMiles, + }); +} + +export async function createEmergencyContact(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const result = await client.query( + ` + INSERT INTO emergency_contacts ( + tenant_id, + staff_id, + full_name, + phone, + relationship_type, + is_primary, + metadata + ) + VALUES ($1, $2, $3, $4, $5, COALESCE($6, FALSE), $7::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + payload.fullName, + normalizePhone(payload.phone), + payload.relationshipType, + payload.isPrimary ?? false, + JSON.stringify(payload.metadata || {}), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'emergency_contact', + aggregateId: result.rows[0].id, + eventType: 'EMERGENCY_CONTACT_CREATED', + actorUserId: actor.uid, + payload, + }); + return { contactId: result.rows[0].id, created: true }; + }); +} + +export async function updateEmergencyContact(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const result = await client.query( + ` + UPDATE emergency_contacts + SET full_name = COALESCE($2, full_name), + phone = COALESCE($3, phone), + relationship_type = COALESCE($4, relationship_type), + is_primary = COALESCE($5, is_primary), + metadata = COALESCE(metadata, '{}'::jsonb) || $6::jsonb, + updated_at = NOW() + WHERE id = $1 + AND tenant_id = $7 + AND staff_id = $8 + RETURNING id + `, + [ + payload.contactId, + payload.fullName || null, + normalizePhone(payload.phone) || null, + payload.relationshipType || null, + payload.isPrimary ?? null, + JSON.stringify(payload.metadata || {}), + context.tenant.tenantId, + staff.id, + ] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Emergency contact not found for current staff user', 404, { + contactId: payload.contactId, + }); + } + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'emergency_contact', + aggregateId: result.rows[0].id, + eventType: 'EMERGENCY_CONTACT_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { contactId: result.rows[0].id, updated: true }; + }); +} + +async function ensureTaxFormDocument(client, tenantId, formType) { + const normalizedName = formType.toUpperCase() === 'I9' ? 'I-9' : 'W-4'; + const result = await client.query( + ` + INSERT INTO documents (tenant_id, document_type, name, metadata) + VALUES ($1, 'TAX_FORM', $2, '{"required":true}'::jsonb) + ON CONFLICT (tenant_id, document_type, name) DO UPDATE + SET updated_at = NOW() + RETURNING id, name + `, + [tenantId, normalizedName] + ); + return result.rows[0]; +} + +export async function saveTaxFormDraft(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const document = await ensureTaxFormDocument(client, context.tenant.tenantId, payload.formType); + const result = await client.query( + ` + INSERT INTO staff_documents ( + tenant_id, + staff_id, + document_id, + file_uri, + status, + metadata + ) + VALUES ($1, $2, $3, NULL, 'PENDING', $4::jsonb) + ON CONFLICT (staff_id, document_id) DO UPDATE + SET metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + document.id, + JSON.stringify({ + formType: document.name, + formStatus: 'IN_PROGRESS', + fields: payload.fields, + lastSavedAt: new Date().toISOString(), + }), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_document', + aggregateId: result.rows[0].id, + eventType: 'TAX_FORM_DRAFT_SAVED', + actorUserId: actor.uid, + payload, + }); + return { staffDocumentId: result.rows[0].id, formType: document.name, status: 'IN_PROGRESS' }; + }); +} + +export async function submitTaxForm(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const document = await ensureTaxFormDocument(client, context.tenant.tenantId, payload.formType); + const result = await client.query( + ` + INSERT INTO staff_documents ( + tenant_id, + staff_id, + document_id, + file_uri, + status, + metadata + ) + VALUES ($1, $2, $3, NULL, 'PENDING', $4::jsonb) + ON CONFLICT (staff_id, document_id) DO UPDATE + SET metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id + `, + [ + context.tenant.tenantId, + staff.id, + document.id, + JSON.stringify({ + formType: document.name, + formStatus: 'SUBMITTED', + submittedAt: new Date().toISOString(), + fields: payload.fields, + }), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff_document', + aggregateId: result.rows[0].id, + eventType: 'TAX_FORM_SUBMITTED', + actorUserId: actor.uid, + payload, + }); + return { staffDocumentId: result.rows[0].id, formType: document.name, status: 'SUBMITTED' }; + }); +} + +export async function addStaffBankAccount(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const existingPrimary = await client.query( + ` + SELECT id + FROM accounts + WHERE tenant_id = $1 + AND owner_staff_id = $2 + AND is_primary = TRUE + LIMIT 1 + `, + [context.tenant.tenantId, staff.id] + ); + const accountResult = await client.query( + ` + INSERT INTO accounts ( + tenant_id, + owner_type, + owner_staff_id, + provider_name, + provider_reference, + last4, + is_primary, + metadata + ) + VALUES ($1, 'STAFF', $2, $3, $4, $5, $6, $7::jsonb) + RETURNING id, last4, is_primary + `, + [ + context.tenant.tenantId, + staff.id, + payload.bankName, + `manual:${payload.routingNumber.slice(-4)}:${payload.accountNumber.slice(-4)}`, + payload.accountNumber.slice(-4), + existingPrimary.rowCount === 0, + JSON.stringify({ + accountType: payload.accountType, + routingNumberMasked: `***${payload.routingNumber.slice(-4)}`, + }), + ] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'account', + aggregateId: accountResult.rows[0].id, + eventType: 'STAFF_BANK_ACCOUNT_ADDED', + actorUserId: actor.uid, + payload: { + accountType: payload.accountType, + bankName: payload.bankName, + }, + }); + return { + accountId: accountResult.rows[0].id, + last4: accountResult.rows[0].last4, + isPrimary: accountResult.rows[0].is_primary, + }; + }); +} + +export async function updatePrivacyVisibility(actor, payload) { + const context = await requireStaffContext(actor.uid); + return withTransaction(async (client) => { + await ensureActorUser(client, actor); + const staff = await requireStaffByActor(client, context.tenant.tenantId, actor.uid); + const nextMetadata = buildStaffMetadataPatch(staff.metadata || {}, { + profileVisible: payload.profileVisible, + }); + await client.query( + ` + UPDATE staffs + SET metadata = $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [staff.id, JSON.stringify(nextMetadata)] + ); + await insertDomainEvent(client, { + tenantId: context.tenant.tenantId, + aggregateType: 'staff', + aggregateId: staff.id, + eventType: 'STAFF_PRIVACY_UPDATED', + actorUserId: actor.uid, + payload, + }); + return { + staffId: staff.id, + profileVisible: Boolean(payload.profileVisible), + }; + }); +} diff --git a/backend/command-api/src/services/notification-device-tokens.js b/backend/command-api/src/services/notification-device-tokens.js new file mode 100644 index 00000000..3ac0da1f --- /dev/null +++ b/backend/command-api/src/services/notification-device-tokens.js @@ -0,0 +1,220 @@ +import crypto from 'node:crypto'; + +export const PUSH_PROVIDERS = { + FCM: 'FCM', + APNS: 'APNS', + WEB_PUSH: 'WEB_PUSH', +}; + +export const PUSH_PLATFORMS = { + IOS: 'IOS', + ANDROID: 'ANDROID', + WEB: 'WEB', +}; + +export function hashPushToken(pushToken) { + return crypto.createHash('sha256').update(`${pushToken || ''}`).digest('hex'); +} + +export async function registerPushToken(client, { + tenantId, + userId, + staffId = null, + businessMembershipId = null, + vendorMembershipId = null, + provider = PUSH_PROVIDERS.FCM, + platform, + pushToken, + deviceId = null, + appVersion = null, + appBuild = null, + locale = null, + timezone = null, + notificationsEnabled = true, + metadata = {}, +}) { + const tokenHash = hashPushToken(pushToken); + const result = await client.query( + ` + INSERT INTO device_push_tokens ( + tenant_id, + user_id, + staff_id, + business_membership_id, + vendor_membership_id, + provider, + platform, + push_token, + token_hash, + device_id, + app_version, + app_build, + locale, + timezone, + notifications_enabled, + invalidated_at, + invalidation_reason, + last_registered_at, + last_seen_at, + metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, NULL, NULL, NOW(), NOW(), $16::jsonb + ) + ON CONFLICT (provider, token_hash) DO UPDATE + SET tenant_id = EXCLUDED.tenant_id, + user_id = EXCLUDED.user_id, + staff_id = EXCLUDED.staff_id, + business_membership_id = EXCLUDED.business_membership_id, + vendor_membership_id = EXCLUDED.vendor_membership_id, + platform = EXCLUDED.platform, + push_token = EXCLUDED.push_token, + device_id = EXCLUDED.device_id, + app_version = EXCLUDED.app_version, + app_build = EXCLUDED.app_build, + locale = EXCLUDED.locale, + timezone = EXCLUDED.timezone, + notifications_enabled = EXCLUDED.notifications_enabled, + invalidated_at = NULL, + invalidation_reason = NULL, + last_registered_at = NOW(), + last_seen_at = NOW(), + metadata = COALESCE(device_push_tokens.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + RETURNING id, + tenant_id AS "tenantId", + user_id AS "userId", + staff_id AS "staffId", + business_membership_id AS "businessMembershipId", + vendor_membership_id AS "vendorMembershipId", + provider, + platform, + device_id AS "deviceId", + notifications_enabled AS "notificationsEnabled" + `, + [ + tenantId, + userId, + staffId, + businessMembershipId, + vendorMembershipId, + provider, + platform, + pushToken, + tokenHash, + deviceId, + appVersion, + appBuild, + locale, + timezone, + notificationsEnabled, + JSON.stringify(metadata || {}), + ] + ); + + return result.rows[0]; +} + +export async function unregisterPushToken(client, { + tenantId, + userId, + tokenId = null, + pushToken = null, + reason = 'USER_REQUESTED', +}) { + const tokenHash = pushToken ? hashPushToken(pushToken) : null; + const result = await client.query( + ` + UPDATE device_push_tokens + SET notifications_enabled = FALSE, + invalidated_at = NOW(), + invalidation_reason = $4, + updated_at = NOW() + WHERE tenant_id = $1 + AND user_id = $2 + AND ( + ($3::uuid IS NOT NULL AND id = $3::uuid) + OR + ($5::text IS NOT NULL AND token_hash = $5::text) + ) + RETURNING id, + provider, + platform, + device_id AS "deviceId" + `, + [tenantId, userId, tokenId, reason, tokenHash] + ); + + return result.rows; +} + +export async function resolveNotificationTargetTokens(client, notification) { + const result = await client.query( + ` + WITH recipient_users AS ( + SELECT $2::text AS user_id + WHERE $2::text IS NOT NULL + UNION + SELECT bm.user_id + FROM business_memberships bm + WHERE $3::uuid IS NOT NULL + AND bm.id = $3::uuid + UNION + SELECT s.user_id + FROM staffs s + WHERE $4::uuid IS NOT NULL + AND s.id = $4::uuid + ) + SELECT + dpt.id, + dpt.user_id AS "userId", + dpt.staff_id AS "staffId", + dpt.provider, + dpt.platform, + dpt.push_token AS "pushToken", + dpt.device_id AS "deviceId", + dpt.metadata + FROM device_push_tokens dpt + JOIN recipient_users ru ON ru.user_id = dpt.user_id + WHERE dpt.tenant_id = $1 + AND dpt.notifications_enabled = TRUE + AND dpt.invalidated_at IS NULL + ORDER BY dpt.last_seen_at DESC, dpt.created_at DESC + `, + [ + notification.tenant_id, + notification.recipient_user_id, + notification.recipient_business_membership_id, + notification.recipient_staff_id, + ] + ); + + return result.rows; +} + +export async function markPushTokenInvalid(client, tokenId, reason) { + await client.query( + ` + UPDATE device_push_tokens + SET notifications_enabled = FALSE, + invalidated_at = NOW(), + invalidation_reason = $2, + updated_at = NOW() + WHERE id = $1 + `, + [tokenId, reason] + ); +} + +export async function touchPushTokenDelivery(client, tokenId) { + await client.query( + ` + UPDATE device_push_tokens + SET last_delivery_at = NOW(), + last_seen_at = NOW(), + updated_at = NOW() + WHERE id = $1 + `, + [tokenId] + ); +} diff --git a/backend/command-api/src/services/notification-dispatcher.js b/backend/command-api/src/services/notification-dispatcher.js new file mode 100644 index 00000000..00fd05cf --- /dev/null +++ b/backend/command-api/src/services/notification-dispatcher.js @@ -0,0 +1,348 @@ +import { query, withTransaction } from './db.js'; +import { enqueueNotification } from './notification-outbox.js'; +import { + markPushTokenInvalid, + resolveNotificationTargetTokens, + touchPushTokenDelivery, +} from './notification-device-tokens.js'; +import { createPushSender } from './notification-fcm.js'; + +function parseIntEnv(name, fallback) { + const parsed = Number.parseInt(`${process.env[name] || fallback}`, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function parseBooleanEnv(name, fallback = false) { + const value = process.env[name]; + if (value == null) return fallback; + return value === 'true'; +} + +function parseListEnv(name, fallback = []) { + const raw = process.env[name]; + if (!raw) return fallback; + return raw.split(',').map((value) => Number.parseInt(value.trim(), 10)).filter((value) => Number.isFinite(value) && value >= 0); +} + +export function computeRetryDelayMinutes(attemptNumber) { + return Math.min(5 * (2 ** Math.max(attemptNumber - 1, 0)), 60); +} + +async function recordDeliveryAttempt(client, { + notificationId, + devicePushTokenId = null, + provider, + deliveryStatus, + providerMessageId = null, + attemptNumber, + errorCode = null, + errorMessage = null, + responsePayload = {}, + sentAt = null, +}) { + await client.query( + ` + INSERT INTO notification_deliveries ( + notification_outbox_id, + device_push_token_id, + provider, + delivery_status, + provider_message_id, + attempt_number, + error_code, + error_message, + response_payload, + sent_at + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::timestamptz) + `, + [ + notificationId, + devicePushTokenId, + provider, + deliveryStatus, + providerMessageId, + attemptNumber, + errorCode, + errorMessage, + JSON.stringify(responsePayload || {}), + sentAt, + ] + ); +} + +async function claimDueNotifications(limit) { + return withTransaction(async (client) => { + const result = await client.query( + ` + WITH due AS ( + SELECT id + FROM notification_outbox + WHERE ( + status = 'PENDING' + OR ( + status = 'PROCESSING' + AND updated_at <= NOW() - INTERVAL '10 minutes' + ) + ) + AND scheduled_at <= NOW() + ORDER BY + CASE priority + WHEN 'CRITICAL' THEN 1 + WHEN 'HIGH' THEN 2 + WHEN 'NORMAL' THEN 3 + ELSE 4 + END, + scheduled_at ASC, + created_at ASC + LIMIT $1 + FOR UPDATE SKIP LOCKED + ) + UPDATE notification_outbox n + SET status = 'PROCESSING', + attempts = n.attempts + 1, + updated_at = NOW() + FROM due + WHERE n.id = due.id + RETURNING n.* + `, + [limit] + ); + return result.rows; + }); +} + +async function markNotificationSent(notificationId) { + await query( + ` + UPDATE notification_outbox + SET status = 'SENT', + sent_at = NOW(), + last_error = NULL, + updated_at = NOW() + WHERE id = $1 + `, + [notificationId] + ); +} + +async function markNotificationFailed(notificationId, lastError) { + await query( + ` + UPDATE notification_outbox + SET status = 'FAILED', + last_error = $2, + updated_at = NOW() + WHERE id = $1 + `, + [notificationId, lastError] + ); +} + +async function requeueNotification(notificationId, attemptNumber, lastError) { + const delayMinutes = computeRetryDelayMinutes(attemptNumber); + await query( + ` + UPDATE notification_outbox + SET status = 'PENDING', + last_error = $2, + scheduled_at = NOW() + (($3::text || ' minutes')::interval), + updated_at = NOW() + WHERE id = $1 + `, + [notificationId, lastError, String(delayMinutes)] + ); +} + +async function enqueueDueShiftReminders() { + const enabled = parseBooleanEnv('SHIFT_REMINDERS_ENABLED', true); + if (!enabled) { + return { enqueued: 0 }; + } + + const leadMinutesList = parseListEnv('SHIFT_REMINDER_LEAD_MINUTES', [60, 15]); + const reminderWindowMinutes = parseIntEnv('SHIFT_REMINDER_WINDOW_MINUTES', 5); + let enqueued = 0; + + await withTransaction(async (client) => { + for (const leadMinutes of leadMinutesList) { + const result = await client.query( + ` + SELECT + a.id, + a.tenant_id, + a.business_id, + a.shift_id, + a.staff_id, + s.title AS shift_title, + s.starts_at, + cp.label AS hub_label, + st.user_id + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + JOIN staffs st ON st.id = a.staff_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.status IN ('ASSIGNED', 'ACCEPTED') + AND st.user_id IS NOT NULL + AND s.starts_at >= NOW() + (($1::int - $2::int) * INTERVAL '1 minute') + AND s.starts_at < NOW() + (($1::int + $2::int) * INTERVAL '1 minute') + `, + [leadMinutes, reminderWindowMinutes] + ); + + for (const row of result.rows) { + const dedupeKey = [ + 'notify', + 'SHIFT_START_REMINDER', + row.id, + leadMinutes, + ].join(':'); + + await enqueueNotification(client, { + tenantId: row.tenant_id, + businessId: row.business_id, + shiftId: row.shift_id, + assignmentId: row.id, + audienceType: 'USER', + recipientUserId: row.user_id, + channel: 'PUSH', + notificationType: 'SHIFT_START_REMINDER', + priority: leadMinutes <= 15 ? 'HIGH' : 'NORMAL', + dedupeKey, + subject: leadMinutes <= 15 ? 'Shift starting soon' : 'Upcoming shift reminder', + body: `${row.shift_title || 'Your shift'} at ${row.hub_label || 'the assigned hub'} starts in ${leadMinutes} minutes`, + payload: { + assignmentId: row.id, + shiftId: row.shift_id, + leadMinutes, + startsAt: row.starts_at, + }, + }); + enqueued += 1; + } + } + }); + + return { enqueued }; +} + +async function settleNotification(notification, deliveryResults, maxAttempts) { + const successCount = deliveryResults.filter((result) => result.deliveryStatus === 'SENT').length; + const simulatedCount = deliveryResults.filter((result) => result.deliveryStatus === 'SIMULATED').length; + const transientCount = deliveryResults.filter((result) => result.transient).length; + const invalidCount = deliveryResults.filter((result) => result.deliveryStatus === 'INVALID_TOKEN').length; + + await withTransaction(async (client) => { + for (const result of deliveryResults) { + await recordDeliveryAttempt(client, { + notificationId: notification.id, + devicePushTokenId: result.tokenId, + provider: result.provider || 'FCM', + deliveryStatus: result.deliveryStatus, + providerMessageId: result.providerMessageId || null, + attemptNumber: notification.attempts, + errorCode: result.errorCode || null, + errorMessage: result.errorMessage || null, + responsePayload: result.responsePayload || {}, + sentAt: result.deliveryStatus === 'SENT' || result.deliveryStatus === 'SIMULATED' + ? new Date().toISOString() + : null, + }); + + if (result.deliveryStatus === 'INVALID_TOKEN' && result.tokenId) { + await markPushTokenInvalid(client, result.tokenId, result.errorCode || 'INVALID_TOKEN'); + } + + if ((result.deliveryStatus === 'SENT' || result.deliveryStatus === 'SIMULATED') && result.tokenId) { + await touchPushTokenDelivery(client, result.tokenId); + } + } + }); + + if (successCount > 0 || simulatedCount > 0) { + await markNotificationSent(notification.id); + return { + status: 'SENT', + successCount, + simulatedCount, + invalidCount, + }; + } + + if (transientCount > 0 && notification.attempts < maxAttempts) { + const errorSummary = deliveryResults + .map((result) => result.errorCode || result.errorMessage || result.deliveryStatus) + .filter(Boolean) + .join('; '); + await requeueNotification(notification.id, notification.attempts, errorSummary || 'Transient delivery failure'); + return { + status: 'REQUEUED', + successCount, + simulatedCount, + invalidCount, + }; + } + + const failureSummary = deliveryResults + .map((result) => result.errorCode || result.errorMessage || result.deliveryStatus) + .filter(Boolean) + .join('; '); + await markNotificationFailed(notification.id, failureSummary || 'Push delivery failed'); + return { + status: 'FAILED', + successCount, + simulatedCount, + invalidCount, + }; +} + +export async function dispatchPendingNotifications({ + limit = parseIntEnv('NOTIFICATION_BATCH_LIMIT', 50), + sender = createPushSender(), +} = {}) { + const maxAttempts = parseIntEnv('NOTIFICATION_MAX_ATTEMPTS', 5); + const reminderSummary = await enqueueDueShiftReminders(); + const claimed = await claimDueNotifications(limit); + + const summary = { + remindersEnqueued: reminderSummary.enqueued, + claimed: claimed.length, + sent: 0, + requeued: 0, + failed: 0, + simulated: 0, + invalidTokens: 0, + skipped: 0, + }; + + for (const notification of claimed) { + const tokens = await resolveNotificationTargetTokens({ query }, notification); + if (tokens.length === 0) { + await withTransaction(async (client) => { + await recordDeliveryAttempt(client, { + notificationId: notification.id, + provider: 'FCM', + deliveryStatus: 'SKIPPED', + attemptNumber: notification.attempts, + errorCode: 'NO_ACTIVE_PUSH_TOKENS', + errorMessage: 'No active push tokens registered for notification recipient', + responsePayload: { recipient: notification.recipient_user_id || notification.recipient_staff_id || notification.recipient_business_membership_id || null }, + }); + }); + await markNotificationFailed(notification.id, 'No active push tokens registered for notification recipient'); + summary.failed += 1; + summary.skipped += 1; + continue; + } + + const deliveryResults = await sender.send(notification, tokens); + const outcome = await settleNotification(notification, deliveryResults, maxAttempts); + if (outcome.status === 'SENT') summary.sent += 1; + if (outcome.status === 'REQUEUED') summary.requeued += 1; + if (outcome.status === 'FAILED') summary.failed += 1; + summary.simulated += outcome.simulatedCount || 0; + summary.invalidTokens += outcome.invalidCount || 0; + } + + return summary; +} diff --git a/backend/command-api/src/services/notification-fcm.js b/backend/command-api/src/services/notification-fcm.js new file mode 100644 index 00000000..a3e8a497 --- /dev/null +++ b/backend/command-api/src/services/notification-fcm.js @@ -0,0 +1,116 @@ +import { getFirebaseAdminMessaging } from './firebase-admin.js'; + +const INVALID_TOKEN_ERROR_CODES = new Set([ + 'messaging/invalid-registration-token', + 'messaging/registration-token-not-registered', +]); + +const TRANSIENT_ERROR_CODES = new Set([ + 'messaging/internal-error', + 'messaging/server-unavailable', + 'messaging/unknown-error', + 'app/network-error', +]); + +function mapPriority(priority) { + return priority === 'CRITICAL' || priority === 'HIGH' ? 'high' : 'normal'; +} + +function buildDataPayload(notification) { + return { + notificationId: notification.id, + notificationType: notification.notification_type, + priority: notification.priority, + tenantId: notification.tenant_id, + businessId: notification.business_id || '', + shiftId: notification.shift_id || '', + assignmentId: notification.assignment_id || '', + payload: JSON.stringify(notification.payload || {}), + }; +} + +export function classifyMessagingError(errorCode) { + if (!errorCode) return 'FAILED'; + if (INVALID_TOKEN_ERROR_CODES.has(errorCode)) return 'INVALID_TOKEN'; + if (TRANSIENT_ERROR_CODES.has(errorCode)) return 'RETRYABLE'; + return 'FAILED'; +} + +export function createPushSender({ deliveryMode = process.env.PUSH_DELIVERY_MODE || 'live' } = {}) { + return { + async send(notification, tokens) { + if (tokens.length === 0) { + return []; + } + + if (deliveryMode === 'log-only') { + return tokens.map((token) => ({ + tokenId: token.id, + deliveryStatus: 'SIMULATED', + provider: token.provider, + providerMessageId: null, + errorCode: null, + errorMessage: null, + responsePayload: { + deliveryMode, + }, + transient: false, + })); + } + + const messages = tokens.map((token) => ({ + token: token.pushToken, + notification: { + title: notification.subject || 'Krow update', + body: notification.body || '', + }, + data: buildDataPayload(notification), + android: { + priority: mapPriority(notification.priority), + }, + apns: { + headers: { + 'apns-priority': mapPriority(notification.priority) === 'high' ? '10' : '5', + }, + }, + })); + + const dryRun = deliveryMode === 'dry-run'; + const response = await getFirebaseAdminMessaging().sendEach(messages, dryRun); + return response.responses.map((item, index) => { + const token = tokens[index]; + if (item.success) { + return { + tokenId: token.id, + deliveryStatus: dryRun ? 'SIMULATED' : 'SENT', + provider: token.provider, + providerMessageId: item.messageId || null, + errorCode: null, + errorMessage: null, + responsePayload: { + deliveryMode, + messageId: item.messageId || null, + }, + transient: false, + }; + } + + const errorCode = item.error?.code || 'messaging/unknown-error'; + const errorMessage = item.error?.message || 'Push delivery failed'; + const classification = classifyMessagingError(errorCode); + return { + tokenId: token.id, + deliveryStatus: classification === 'INVALID_TOKEN' ? 'INVALID_TOKEN' : 'FAILED', + provider: token.provider, + providerMessageId: null, + errorCode, + errorMessage, + responsePayload: { + deliveryMode, + }, + transient: classification === 'RETRYABLE', + }; + }); + }, + }; +} diff --git a/backend/command-api/src/services/notification-outbox.js b/backend/command-api/src/services/notification-outbox.js new file mode 100644 index 00000000..bc520e57 --- /dev/null +++ b/backend/command-api/src/services/notification-outbox.js @@ -0,0 +1,196 @@ +export async function enqueueNotification(client, { + tenantId, + businessId = null, + shiftId = null, + assignmentId = null, + relatedIncidentId = null, + audienceType = 'USER', + recipientUserId = null, + recipientStaffId = null, + recipientBusinessMembershipId = null, + channel = 'PUSH', + notificationType, + priority = 'NORMAL', + dedupeKey = null, + subject = null, + body = null, + payload = {}, + scheduledAt = null, +}) { + await client.query( + ` + INSERT INTO notification_outbox ( + tenant_id, + business_id, + shift_id, + assignment_id, + related_incident_id, + audience_type, + recipient_user_id, + recipient_staff_id, + recipient_business_membership_id, + channel, + notification_type, + priority, + dedupe_key, + subject, + body, + payload, + scheduled_at + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16::jsonb, COALESCE($17::timestamptz, NOW()) + ) + ON CONFLICT (dedupe_key) DO NOTHING + `, + [ + tenantId, + businessId, + shiftId, + assignmentId, + relatedIncidentId, + audienceType, + recipientUserId, + recipientStaffId, + recipientBusinessMembershipId, + channel, + notificationType, + priority, + dedupeKey, + subject, + body, + JSON.stringify(payload || {}), + scheduledAt, + ] + ); +} + +async function loadHubNotificationRecipients(client, { tenantId, businessId, hubId }) { + const scoped = await client.query( + ` + SELECT DISTINCT + hm.business_membership_id AS "businessMembershipId", + bm.user_id AS "userId" + FROM hub_managers hm + JOIN business_memberships bm ON bm.id = hm.business_membership_id + WHERE hm.tenant_id = $1 + AND hm.hub_id = $2 + AND bm.membership_status = 'ACTIVE' + `, + [tenantId, hubId] + ); + + if (scoped.rowCount > 0) { + return scoped.rows; + } + + const fallback = await client.query( + ` + SELECT id AS "businessMembershipId", user_id AS "userId" + FROM business_memberships + WHERE tenant_id = $1 + AND business_id = $2 + AND membership_status = 'ACTIVE' + AND business_role IN ('owner', 'manager') + `, + [tenantId, businessId] + ); + return fallback.rows; +} + +export async function enqueueHubManagerAlert(client, { + tenantId, + businessId, + shiftId = null, + assignmentId = null, + hubId = null, + relatedIncidentId = null, + notificationType, + priority = 'HIGH', + subject, + body, + payload = {}, + dedupeScope, +}) { + if (!hubId && !businessId) { + return 0; + } + + const recipients = await loadHubNotificationRecipients(client, { + tenantId, + businessId, + hubId, + }); + + let createdCount = 0; + for (const recipient of recipients) { + const dedupeKey = [ + 'notify', + notificationType, + dedupeScope || shiftId || assignmentId || relatedIncidentId || hubId || businessId, + recipient.userId || recipient.businessMembershipId, + ].filter(Boolean).join(':'); + + await enqueueNotification(client, { + tenantId, + businessId, + shiftId, + assignmentId, + relatedIncidentId, + audienceType: recipient.userId ? 'USER' : 'BUSINESS_MEMBERSHIP', + recipientUserId: recipient.userId || null, + recipientBusinessMembershipId: recipient.businessMembershipId || null, + channel: 'PUSH', + notificationType, + priority, + dedupeKey, + subject, + body, + payload, + }); + createdCount += 1; + } + + return createdCount; +} + +export async function enqueueUserAlert(client, { + tenantId, + businessId = null, + shiftId = null, + assignmentId = null, + relatedIncidentId = null, + recipientUserId, + notificationType, + priority = 'NORMAL', + subject = null, + body = null, + payload = {}, + dedupeScope, +}) { + if (!recipientUserId) return; + + const dedupeKey = [ + 'notify', + notificationType, + dedupeScope || shiftId || assignmentId || relatedIncidentId || recipientUserId, + recipientUserId, + ].filter(Boolean).join(':'); + + await enqueueNotification(client, { + tenantId, + businessId, + shiftId, + assignmentId, + relatedIncidentId, + audienceType: 'USER', + recipientUserId, + channel: 'PUSH', + notificationType, + priority, + dedupeKey, + subject, + body, + payload, + }); +} diff --git a/backend/command-api/src/worker-app.js b/backend/command-api/src/worker-app.js new file mode 100644 index 00000000..8ccd96ed --- /dev/null +++ b/backend/command-api/src/worker-app.js @@ -0,0 +1,46 @@ +import express from 'express'; +import pino from 'pino'; +import pinoHttp from 'pino-http'; + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +export function createWorkerApp({ dispatch = async () => ({}) } = {}) { + const app = express(); + + app.use( + pinoHttp({ + logger, + }) + ); + app.use(express.json({ limit: '256kb' })); + + app.get('/health', (_req, res) => { + res.status(200).json({ ok: true, service: 'notification-worker-v2' }); + }); + + app.get('/readyz', (_req, res) => { + res.status(200).json({ ok: true, service: 'notification-worker-v2' }); + }); + + app.post('/tasks/dispatch-notifications', async (req, res) => { + try { + const summary = await dispatch(); + res.status(200).json({ ok: true, summary }); + } catch (error) { + req.log?.error?.({ err: error }, 'notification dispatch failed'); + res.status(500).json({ + ok: false, + error: error?.message || String(error), + }); + } + }); + + app.use((_req, res) => { + res.status(404).json({ + code: 'NOT_FOUND', + message: 'Route not found', + }); + }); + + return app; +} diff --git a/backend/command-api/src/worker-server.js b/backend/command-api/src/worker-server.js new file mode 100644 index 00000000..fbff2ec4 --- /dev/null +++ b/backend/command-api/src/worker-server.js @@ -0,0 +1,12 @@ +import { createWorkerApp } from './worker-app.js'; +import { dispatchPendingNotifications } from './services/notification-dispatcher.js'; + +const port = Number(process.env.PORT || 8080); +const app = createWorkerApp({ + dispatch: () => dispatchPendingNotifications(), +}); + +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`krow-notification-worker listening on port ${port}`); +}); diff --git a/backend/command-api/test/app.test.js b/backend/command-api/test/app.test.js index bce88d82..ad1a91c3 100644 --- a/backend/command-api/test/app.test.js +++ b/backend/command-api/test/app.test.js @@ -6,9 +6,42 @@ import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-sto process.env.AUTH_BYPASS = 'true'; +const tenantId = '11111111-1111-4111-8111-111111111111'; +const businessId = '22222222-2222-4222-8222-222222222222'; +const shiftId = '33333333-3333-4333-8333-333333333333'; + +function validOrderCreatePayload() { + return { + tenantId, + businessId, + orderNumber: 'ORD-1001', + title: 'Cafe Event Staffing', + serviceType: 'EVENT', + shifts: [ + { + shiftCode: 'SHIFT-1', + title: 'Morning Shift', + startsAt: '2026-03-11T08:00:00.000Z', + endsAt: '2026-03-11T16:00:00.000Z', + requiredWorkers: 2, + roles: [ + { + roleCode: 'BARISTA', + roleName: 'Barista', + workersNeeded: 2, + payRateCents: 2200, + billRateCents: 3500, + }, + ], + }, + ], + }; +} + beforeEach(() => { process.env.IDEMPOTENCY_STORE = 'memory'; delete process.env.IDEMPOTENCY_DATABASE_URL; + delete process.env.DATABASE_URL; __resetIdempotencyStoreForTests(); }); @@ -21,34 +54,65 @@ test('GET /healthz returns healthy response', async () => { assert.equal(typeof res.body.requestId, 'string'); }); +test('GET /readyz reports database not configured when no database env is present', async () => { + const app = createApp(); + const res = await request(app).get('/readyz'); + + assert.equal(res.status, 503); + assert.equal(res.body.ok, false); + assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); +}); + test('command route requires idempotency key', async () => { const app = createApp(); const res = await request(app) .post('/commands/orders/create') .set('Authorization', 'Bearer test-token') - .send({ payload: {} }); + .send(validOrderCreatePayload()); assert.equal(res.status, 400); assert.equal(res.body.code, 'MISSING_IDEMPOTENCY_KEY'); }); -test('command route is idempotent by key', async () => { - const app = createApp(); +test('command route is idempotent by key and only executes handler once', async () => { + let callCount = 0; + const app = createApp({ + commandHandlers: { + createOrder: async () => { + callCount += 1; + return { + orderId: '44444444-4444-4444-8444-444444444444', + orderNumber: 'ORD-1001', + status: 'OPEN', + shiftCount: 1, + shiftIds: [shiftId], + }; + }, + acceptShift: async () => assert.fail('acceptShift should not be called'), + clockIn: async () => assert.fail('clockIn should not be called'), + clockOut: async () => assert.fail('clockOut should not be called'), + addFavoriteStaff: async () => assert.fail('addFavoriteStaff should not be called'), + removeFavoriteStaff: async () => assert.fail('removeFavoriteStaff should not be called'), + createStaffReview: async () => assert.fail('createStaffReview should not be called'), + }, + }); const first = await request(app) .post('/commands/orders/create') .set('Authorization', 'Bearer test-token') .set('Idempotency-Key', 'abc-123') - .send({ payload: { order: 'x' } }); + .send(validOrderCreatePayload()); const second = await request(app) .post('/commands/orders/create') .set('Authorization', 'Bearer test-token') .set('Idempotency-Key', 'abc-123') - .send({ payload: { order: 'x' } }); + .send(validOrderCreatePayload()); assert.equal(first.status, 200); assert.equal(second.status, 200); - assert.equal(first.body.commandId, second.body.commandId); + assert.equal(callCount, 1); + assert.equal(first.body.orderId, second.body.orderId); assert.equal(first.body.idempotencyKey, 'abc-123'); + assert.equal(second.body.idempotencyKey, 'abc-123'); }); diff --git a/backend/command-api/test/mobile-routes.test.js b/backend/command-api/test/mobile-routes.test.js new file mode 100644 index 00000000..466e1b48 --- /dev/null +++ b/backend/command-api/test/mobile-routes.test.js @@ -0,0 +1,344 @@ +import test, { beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; +import { __resetIdempotencyStoreForTests } from '../src/services/idempotency-store.js'; + +process.env.AUTH_BYPASS = 'true'; + +beforeEach(() => { + process.env.IDEMPOTENCY_STORE = 'memory'; + delete process.env.IDEMPOTENCY_DATABASE_URL; + delete process.env.DATABASE_URL; + __resetIdempotencyStoreForTests(); +}); + +function createMobileHandlers() { + return { + createClientOneTimeOrder: async (_actor, payload) => ({ + orderId: 'order-1', + orderType: 'ONE_TIME', + eventName: payload.eventName, + }), + createClientRecurringOrder: async (_actor, payload) => ({ + orderId: 'order-2', + orderType: 'RECURRING', + recurrenceDays: payload.recurrenceDays, + }), + createClientPermanentOrder: async (_actor, payload) => ({ + orderId: 'order-3', + orderType: 'PERMANENT', + horizonDays: payload.horizonDays || 28, + }), + createEditedOrderCopy: async (_actor, payload) => ({ + sourceOrderId: payload.orderId, + orderId: 'order-4', + cloned: true, + }), + cancelClientOrder: async (_actor, payload) => ({ + orderId: payload.orderId, + status: 'CANCELLED', + }), + createHub: async (_actor, payload) => ({ + hubId: 'hub-1', + name: payload.name, + costCenterId: payload.costCenterId, + }), + approveInvoice: async (_actor, payload) => ({ + invoiceId: payload.invoiceId, + status: 'APPROVED', + }), + registerClientPushToken: async (_actor, payload) => ({ + tokenId: 'push-token-client-1', + platform: payload.platform, + notificationsEnabled: payload.notificationsEnabled ?? true, + }), + unregisterClientPushToken: async () => ({ + removedCount: 1, + }), + applyForShift: async (_actor, payload) => ({ + shiftId: payload.shiftId, + status: 'APPLIED', + }), + registerStaffPushToken: async (_actor, payload) => ({ + tokenId: 'push-token-staff-1', + platform: payload.platform, + notificationsEnabled: payload.notificationsEnabled ?? true, + }), + unregisterStaffPushToken: async () => ({ + removedCount: 1, + }), + staffClockIn: async (_actor, payload) => ({ + assignmentId: payload.assignmentId || 'assignment-1', + status: 'CLOCK_IN', + proofNonce: payload.proofNonce || null, + }), + staffClockOut: async (_actor, payload) => ({ + assignmentId: payload.assignmentId || 'assignment-1', + status: 'CLOCK_OUT', + }), + submitLocationStreamBatch: async (_actor, payload) => ({ + assignmentId: payload.assignmentId || 'assignment-1', + pointCount: payload.points.length, + status: 'RECORDED', + }), + saveTaxFormDraft: async (_actor, payload) => ({ + formType: payload.formType, + status: 'DRAFT', + }), + addStaffBankAccount: async (_actor, payload) => ({ + accountType: payload.accountType, + last4: payload.accountNumber.slice(-4), + }), + }; +} + +test('POST /commands/client/orders/one-time forwards one-time order payload', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/orders/one-time') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'client-order-1') + .send({ + hubId: '11111111-1111-4111-8111-111111111111', + vendorId: '22222222-2222-4222-8222-222222222222', + eventName: 'Google Cafe Coverage', + orderDate: '2026-03-20', + positions: [ + { + roleId: '33333333-3333-4333-8333-333333333333', + startTime: '09:00', + endTime: '17:00', + workerCount: 2, + hourlyRateCents: 2800, + }, + ], + }); + + assert.equal(res.status, 200); + assert.equal(res.body.orderId, 'order-1'); + assert.equal(res.body.orderType, 'ONE_TIME'); + assert.equal(res.body.eventName, 'Google Cafe Coverage'); +}); + +test('POST /commands/client/orders/:orderId/edit injects order id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/orders/44444444-4444-4444-8444-444444444444/edit') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'client-order-edit-1') + .send({ + eventName: 'Edited Order Copy', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.sourceOrderId, '44444444-4444-4444-8444-444444444444'); + assert.equal(res.body.cloned, true); +}); + +test('POST /commands/client/hubs returns injected hub response', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/hubs') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'hub-create-1') + .send({ + tenantId: '11111111-1111-4111-8111-111111111111', + businessId: '22222222-2222-4222-8222-222222222222', + name: 'Google North Hub', + locationName: 'North Campus', + timezone: 'America/Los_Angeles', + latitude: 37.422, + longitude: -122.084, + geofenceRadiusMeters: 100, + clockInMode: 'GEO_REQUIRED', + allowClockInOverride: true, + costCenterId: '44444444-4444-4444-8444-444444444444', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.hubId, 'hub-1'); + assert.equal(res.body.name, 'Google North Hub'); +}); + +test('POST /commands/client/billing/invoices/:invoiceId/approve injects invoice id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/billing/invoices/55555555-5555-4555-8555-555555555555/approve') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'invoice-approve-1') + .send({}); + + assert.equal(res.status, 200); + assert.equal(res.body.invoiceId, '55555555-5555-4555-8555-555555555555'); + assert.equal(res.body.status, 'APPROVED'); +}); + +test('POST /commands/client/devices/push-tokens registers a client push token', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/client/devices/push-tokens') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'client-push-token-1') + .send({ + provider: 'FCM', + platform: 'IOS', + pushToken: 'f'.repeat(160), + deviceId: 'iphone-15-pro', + notificationsEnabled: true, + }); + + assert.equal(res.status, 200); + assert.equal(res.body.tokenId, 'push-token-client-1'); + assert.equal(res.body.platform, 'IOS'); +}); + +test('DELETE /commands/client/devices/push-tokens accepts tokenId from query params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .delete('/commands/client/devices/push-tokens?tokenId=11111111-1111-4111-8111-111111111111&reason=SMOKE_CLEANUP') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'client-push-token-delete-1'); + + assert.equal(res.status, 200); + assert.equal(res.body.removedCount, 1); +}); + +test('POST /commands/staff/shifts/:shiftId/apply injects shift id from params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/shifts/66666666-6666-4666-8666-666666666666/apply') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'shift-apply-1') + .send({ + note: 'Available tonight', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.shiftId, '66666666-6666-4666-8666-666666666666'); + assert.equal(res.body.status, 'APPLIED'); +}); + +test('POST /commands/staff/clock-in accepts shift-based payload', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/clock-in') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'clock-in-1') + .send({ + shiftId: '77777777-7777-4777-8777-777777777777', + sourceType: 'GEO', + latitude: 37.422, + longitude: -122.084, + proofNonce: 'nonce-12345678', + overrideReason: 'GPS timed out near the hub', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.status, 'CLOCK_IN'); + assert.equal(res.body.proofNonce, 'nonce-12345678'); +}); + +test('POST /commands/staff/clock-out accepts assignment-based payload', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/clock-out') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'clock-out-1') + .send({ + assignmentId: '88888888-8888-4888-8888-888888888888', + breakMinutes: 30, + }); + + assert.equal(res.status, 200); + assert.equal(res.body.status, 'CLOCK_OUT'); +}); + +test('POST /commands/staff/location-streams accepts batched location payloads', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/location-streams') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'location-stream-1') + .send({ + assignmentId: '99999999-9999-4999-8999-999999999999', + sourceType: 'GEO', + deviceId: 'iphone-15', + points: [ + { + capturedAt: '2026-03-16T08:00:00.000Z', + latitude: 37.422, + longitude: -122.084, + accuracyMeters: 12, + }, + ], + }); + + assert.equal(res.status, 200); + assert.equal(res.body.status, 'RECORDED'); + assert.equal(res.body.pointCount, 1); +}); + +test('POST /commands/staff/devices/push-tokens registers a staff push token', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/devices/push-tokens') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'staff-push-token-1') + .send({ + provider: 'FCM', + platform: 'ANDROID', + pushToken: 'g'.repeat(170), + deviceId: 'pixel-9', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.tokenId, 'push-token-staff-1'); + assert.equal(res.body.platform, 'ANDROID'); +}); + +test('DELETE /commands/staff/devices/push-tokens accepts tokenId from query params', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .delete('/commands/staff/devices/push-tokens?tokenId=22222222-2222-4222-8222-222222222222&reason=SMOKE_CLEANUP') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'staff-push-token-delete-1'); + + assert.equal(res.status, 200); + assert.equal(res.body.removedCount, 1); +}); + +test('PUT /commands/staff/profile/tax-forms/:formType uppercases form type', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .put('/commands/staff/profile/tax-forms/w4') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'tax-form-1') + .send({ + fields: { + filingStatus: 'single', + }, + }); + + assert.equal(res.status, 200); + assert.equal(res.body.formType, 'W4'); + assert.equal(res.body.status, 'DRAFT'); +}); + +test('POST /commands/staff/profile/bank-accounts uppercases account type', async () => { + const app = createApp({ mobileCommandHandlers: createMobileHandlers() }); + const res = await request(app) + .post('/commands/staff/profile/bank-accounts') + .set('Authorization', 'Bearer test-token') + .set('Idempotency-Key', 'bank-account-1') + .send({ + bankName: 'Demo Credit Union', + accountNumber: '1234567890', + routingNumber: '021000021', + accountType: 'checking', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.accountType, 'CHECKING'); + assert.equal(res.body.last4, '7890'); +}); diff --git a/backend/command-api/test/notification-dispatcher.test.js b/backend/command-api/test/notification-dispatcher.test.js new file mode 100644 index 00000000..9f0d6d62 --- /dev/null +++ b/backend/command-api/test/notification-dispatcher.test.js @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { computeRetryDelayMinutes } from '../src/services/notification-dispatcher.js'; +import { createPushSender, classifyMessagingError } from '../src/services/notification-fcm.js'; + +test('computeRetryDelayMinutes backs off exponentially with a cap', () => { + assert.equal(computeRetryDelayMinutes(1), 5); + assert.equal(computeRetryDelayMinutes(2), 10); + assert.equal(computeRetryDelayMinutes(3), 20); + assert.equal(computeRetryDelayMinutes(5), 60); + assert.equal(computeRetryDelayMinutes(9), 60); +}); + +test('classifyMessagingError distinguishes invalid and retryable push failures', () => { + assert.equal(classifyMessagingError('messaging/registration-token-not-registered'), 'INVALID_TOKEN'); + assert.equal(classifyMessagingError('messaging/server-unavailable'), 'RETRYABLE'); + assert.equal(classifyMessagingError('messaging/unknown-problem'), 'FAILED'); +}); + +test('createPushSender log-only mode simulates successful delivery results', async () => { + const sender = createPushSender({ deliveryMode: 'log-only' }); + const results = await sender.send( + { + id: 'notification-1', + notification_type: 'SHIFT_START_REMINDER', + priority: 'HIGH', + tenant_id: 'tenant-1', + payload: { assignmentId: 'assignment-1' }, + }, + [ + { id: 'token-1', provider: 'FCM', pushToken: 'demo-token' }, + ] + ); + + assert.equal(results.length, 1); + assert.equal(results[0].deliveryStatus, 'SIMULATED'); + assert.equal(results[0].transient, false); +}); diff --git a/backend/command-api/test/notification-worker.test.js b/backend/command-api/test/notification-worker.test.js new file mode 100644 index 00000000..a4865b55 --- /dev/null +++ b/backend/command-api/test/notification-worker.test.js @@ -0,0 +1,47 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createWorkerApp } from '../src/worker-app.js'; + +test('GET /readyz returns healthy response', async () => { + const app = createWorkerApp(); + const res = await request(app).get('/readyz'); + + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(res.body.service, 'notification-worker-v2'); +}); + +test('POST /tasks/dispatch-notifications returns dispatch summary', async () => { + const app = createWorkerApp({ + dispatch: async () => ({ + claimed: 2, + sent: 2, + }), + }); + + const res = await request(app) + .post('/tasks/dispatch-notifications') + .send({}); + + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(res.body.summary.claimed, 2); + assert.equal(res.body.summary.sent, 2); +}); + +test('POST /tasks/dispatch-notifications returns 500 on dispatch error', async () => { + const app = createWorkerApp({ + dispatch: async () => { + throw new Error('dispatch exploded'); + }, + }); + + const res = await request(app) + .post('/tasks/dispatch-notifications') + .send({}); + + assert.equal(res.status, 500); + assert.equal(res.body.ok, false); + assert.match(res.body.error, /dispatch exploded/); +}); diff --git a/backend/core-api/package-lock.json b/backend/core-api/package-lock.json index 87370c92..3155f437 100644 --- a/backend/core-api/package-lock.json +++ b/backend/core-api/package-lock.json @@ -13,6 +13,7 @@ "firebase-admin": "^13.0.2", "google-auth-library": "^9.15.1", "multer": "^2.0.2", + "pg": "^8.20.0", "pino": "^9.6.0", "pino-http": "^10.3.0", "zod": "^3.24.2" @@ -2037,6 +2038,95 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/pino": { "version": "9.14.0", "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", @@ -2086,6 +2176,45 @@ "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", "license": "MIT" }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", diff --git a/backend/core-api/package.json b/backend/core-api/package.json index 0e9b2f6d..cf7bccca 100644 --- a/backend/core-api/package.json +++ b/backend/core-api/package.json @@ -16,6 +16,7 @@ "firebase-admin": "^13.0.2", "google-auth-library": "^9.15.1", "multer": "^2.0.2", + "pg": "^8.20.0", "pino": "^9.6.0", "pino-http": "^10.3.0", "zod": "^3.24.2" diff --git a/backend/core-api/src/routes/core.js b/backend/core-api/src/routes/core.js index 6c905278..40a0ebe8 100644 --- a/backend/core-api/src/routes/core.js +++ b/backend/core-api/src/routes/core.js @@ -24,6 +24,12 @@ import { retryVerificationJob, reviewVerificationJob, } from '../services/verification-jobs.js'; +import { + deleteCertificate, + uploadCertificate, + uploadProfilePhoto, + uploadStaffDocument, +} from '../services/mobile-upload.js'; const DEFAULT_MAX_FILE_BYTES = 10 * 1024 * 1024; const DEFAULT_MAX_SIGNED_URL_SECONDS = 900; @@ -56,6 +62,14 @@ const uploadMetaSchema = z.object({ visibility: z.enum(['public', 'private']).optional(), }); +const certificateUploadMetaSchema = z.object({ + certificateType: z.string().min(1).max(120), + name: z.string().min(1).max(160), + issuer: z.string().max(160).optional(), + certificateNumber: z.string().max(160).optional(), + expiresAt: z.string().datetime().optional(), +}); + function mockSignedUrl(fileUri, expiresInSeconds) { const encoded = encodeURIComponent(fileUri); const expiresAt = new Date(Date.now() + expiresInSeconds * 1000).toISOString(); @@ -292,7 +306,7 @@ async function handleCreateVerification(req, res, next) { }); } - const created = createVerificationJob({ + const created = await createVerificationJob({ actorUid: req.actor.uid, payload, }); @@ -305,10 +319,107 @@ async function handleCreateVerification(req, res, next) { } } +async function handleProfilePhotoUpload(req, res, next) { + try { + const file = req.file; + if (!file) { + throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); + } + const result = await uploadProfilePhoto({ + actorUid: req.actor.uid, + file, + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleDocumentUpload(req, res, next) { + try { + const file = req.file; + if (!file) { + throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); + } + const result = await uploadStaffDocument({ + actorUid: req.actor.uid, + documentId: req.params.documentId, + file, + routeType: 'document', + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleAttireUpload(req, res, next) { + try { + const file = req.file; + if (!file) { + throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); + } + const result = await uploadStaffDocument({ + actorUid: req.actor.uid, + documentId: req.params.documentId, + file, + routeType: 'attire', + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleCertificateUpload(req, res, next) { + try { + const file = req.file; + if (!file) { + throw new AppError('INVALID_FILE', 'Missing file in multipart form data', 400); + } + const payload = parseBody(certificateUploadMetaSchema, req.body || {}); + const result = await uploadCertificate({ + actorUid: req.actor.uid, + file, + payload, + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + +async function handleCertificateDelete(req, res, next) { + try { + const result = await deleteCertificate({ + actorUid: req.actor.uid, + certificateType: req.params.certificateType, + }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } +} + async function handleGetVerification(req, res, next) { try { const verificationId = req.params.verificationId; - const job = getVerificationJob(verificationId, req.actor.uid); + const job = await getVerificationJob(verificationId, req.actor.uid); return res.status(200).json({ ...job, requestId: req.requestId, @@ -322,7 +433,7 @@ async function handleReviewVerification(req, res, next) { try { const verificationId = req.params.verificationId; const payload = parseBody(reviewVerificationSchema, req.body || {}); - const updated = reviewVerificationJob(verificationId, req.actor.uid, payload); + const updated = await reviewVerificationJob(verificationId, req.actor.uid, payload); return res.status(200).json({ ...updated, requestId: req.requestId, @@ -335,7 +446,7 @@ async function handleReviewVerification(req, res, next) { async function handleRetryVerification(req, res, next) { try { const verificationId = req.params.verificationId; - const updated = retryVerificationJob(verificationId, req.actor.uid); + const updated = await retryVerificationJob(verificationId, req.actor.uid); return res.status(202).json({ ...updated, requestId: req.requestId, @@ -353,6 +464,11 @@ export function createCoreRouter() { router.post('/invoke-llm', requireAuth, requirePolicy('core.invoke-llm', 'model'), handleInvokeLlm); router.post('/rapid-orders/transcribe', requireAuth, requirePolicy('core.rapid-order.transcribe', 'model'), handleRapidOrderTranscribe); router.post('/rapid-orders/parse', requireAuth, requirePolicy('core.rapid-order.parse', 'model'), handleRapidOrderParse); + router.post('/staff/profile/photo', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleProfilePhotoUpload); + router.post('/staff/documents/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleDocumentUpload); + router.post('/staff/attire/:documentId/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleAttireUpload); + router.post('/staff/certificates/upload', requireAuth, requirePolicy('core.upload', 'file'), upload.single('file'), handleCertificateUpload); + router.delete('/staff/certificates/:certificateType', requireAuth, requirePolicy('core.upload', 'file'), handleCertificateDelete); router.post('/verifications', requireAuth, requirePolicy('core.verification.create', 'verification'), handleCreateVerification); router.get('/verifications/:verificationId', requireAuth, requirePolicy('core.verification.read', 'verification'), handleGetVerification); router.post('/verifications/:verificationId/review', requireAuth, requirePolicy('core.verification.review', 'verification'), handleReviewVerification); diff --git a/backend/core-api/src/routes/health.js b/backend/core-api/src/routes/health.js index 9196cc83..9ccd539c 100644 --- a/backend/core-api/src/routes/health.js +++ b/backend/core-api/src/routes/health.js @@ -1,4 +1,5 @@ import { Router } from 'express'; +import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js'; export const healthRouter = Router(); @@ -13,3 +14,31 @@ function healthHandler(req, res) { healthRouter.get('/health', healthHandler); healthRouter.get('/healthz', healthHandler); + +healthRouter.get('/readyz', async (req, res) => { + if (!isDatabaseConfigured()) { + return res.status(503).json({ + ok: false, + service: 'krow-core-api', + status: 'DATABASE_NOT_CONFIGURED', + requestId: req.requestId, + }); + } + + const healthy = await checkDatabaseHealth().catch(() => false); + if (!healthy) { + return res.status(503).json({ + ok: false, + service: 'krow-core-api', + status: 'DATABASE_UNAVAILABLE', + requestId: req.requestId, + }); + } + + return res.status(200).json({ + ok: true, + service: 'krow-core-api', + status: 'READY', + requestId: req.requestId, + }); +}); diff --git a/backend/core-api/src/services/actor-context.js b/backend/core-api/src/services/actor-context.js new file mode 100644 index 00000000..ae32d932 --- /dev/null +++ b/backend/core-api/src/services/actor-context.js @@ -0,0 +1,67 @@ +import { AppError } from '../lib/errors.js'; +import { query } from './db.js'; + +export async function loadActorContext(uid) { + const [userResult, tenantResult, staffResult] = await Promise.all([ + query( + ` + SELECT id AS "userId", email, display_name AS "displayName", phone, status + FROM users + WHERE id = $1 + `, + [uid] + ), + query( + ` + SELECT tm.id AS "membershipId", + tm.tenant_id AS "tenantId", + tm.base_role AS role, + t.name AS "tenantName", + t.slug AS "tenantSlug" + FROM tenant_memberships tm + JOIN tenants t ON t.id = tm.tenant_id + WHERE tm.user_id = $1 + AND tm.membership_status = 'ACTIVE' + ORDER BY tm.created_at ASC + LIMIT 1 + `, + [uid] + ), + query( + ` + SELECT s.id AS "staffId", + s.tenant_id AS "tenantId", + s.full_name AS "fullName", + s.status, + s.metadata + FROM staffs s + WHERE s.user_id = $1 + ORDER BY s.created_at ASC + LIMIT 1 + `, + [uid] + ), + ]); + + return { + user: userResult.rows[0] || null, + tenant: tenantResult.rows[0] || null, + staff: staffResult.rows[0] || null, + }; +} + +export async function requireTenantContext(uid) { + const context = await loadActorContext(uid); + if (!context.user || !context.tenant) { + throw new AppError('FORBIDDEN', 'Tenant context is required for this route', 403, { uid }); + } + return context; +} + +export async function requireStaffContext(uid) { + const context = await loadActorContext(uid); + if (!context.user || !context.tenant || !context.staff) { + throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid }); + } + return context; +} diff --git a/backend/core-api/src/services/db.js b/backend/core-api/src/services/db.js new file mode 100644 index 00000000..2755155c --- /dev/null +++ b/backend/core-api/src/services/db.js @@ -0,0 +1,98 @@ +import pg from 'pg'; + +const { Pool, types } = pg; + +function parseNumericDatabaseValue(value) { + if (value == null) return value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; +} + +types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue); +types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue); + +let pool; + +function parseIntOrDefault(value, fallback) { + const parsed = Number.parseInt(`${value || fallback}`, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function resolveDatabasePoolConfig() { + if (process.env.DATABASE_URL) { + return { + connectionString: process.env.DATABASE_URL, + max: parseIntOrDefault(process.env.DB_POOL_MAX, 10), + idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000), + }; + } + + const user = process.env.DB_USER; + const password = process.env.DB_PASSWORD; + const database = process.env.DB_NAME; + const host = process.env.DB_HOST || ( + process.env.INSTANCE_CONNECTION_NAME + ? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}` + : '' + ); + + if (!user || password == null || !database || !host) { + return null; + } + + return { + host, + port: parseIntOrDefault(process.env.DB_PORT, 5432), + user, + password, + database, + max: parseIntOrDefault(process.env.DB_POOL_MAX, 10), + idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000), + }; +} + +export function isDatabaseConfigured() { + return Boolean(resolveDatabasePoolConfig()); +} + +function getPool() { + if (!pool) { + const resolved = resolveDatabasePoolConfig(); + if (!resolved) { + throw new Error('Database connection settings are required'); + } + pool = new Pool(resolved); + } + return pool; +} + +export async function query(text, params = []) { + return getPool().query(text, params); +} + +export async function withTransaction(work) { + const client = await getPool().connect(); + try { + await client.query('BEGIN'); + const result = await work(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +export async function checkDatabaseHealth() { + const result = await query('SELECT 1 AS ok'); + return result.rows[0]?.ok === 1; +} + +export async function closePool() { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/backend/core-api/src/services/mobile-upload.js b/backend/core-api/src/services/mobile-upload.js new file mode 100644 index 00000000..392c9076 --- /dev/null +++ b/backend/core-api/src/services/mobile-upload.js @@ -0,0 +1,260 @@ +import { AppError } from '../lib/errors.js'; +import { requireStaffContext } from './actor-context.js'; +import { generateReadSignedUrl, uploadToGcs } from './storage.js'; +import { query, withTransaction } from './db.js'; +import { createVerificationJob } from './verification-jobs.js'; + +function safeName(value) { + return `${value}`.replace(/[^a-zA-Z0-9._-]/g, '_'); +} + +function uploadBucket() { + return process.env.PRIVATE_BUCKET || 'krow-workforce-dev-private'; +} + +async function uploadActorFile({ actorUid, file, category }) { + const bucket = uploadBucket(); + const objectPath = `uploads/${actorUid}/${category}/${Date.now()}_${safeName(file.originalname)}`; + const fileUri = `gs://${bucket}/${objectPath}`; + await uploadToGcs({ + bucket, + objectPath, + contentType: file.mimetype, + buffer: file.buffer, + }); + return { bucket, objectPath, fileUri }; +} + +async function createPreviewUrl(actorUid, fileUri) { + try { + return await generateReadSignedUrl({ + fileUri, + actorUid, + expiresInSeconds: 900, + }); + } catch { + return { + signedUrl: null, + expiresAt: null, + }; + } +} + +export async function uploadProfilePhoto({ actorUid, file }) { + const context = await requireStaffContext(actorUid); + const uploaded = await uploadActorFile({ + actorUid, + file, + category: 'profile-photo', + }); + + await withTransaction(async (client) => { + await client.query( + ` + UPDATE staffs + SET metadata = COALESCE(metadata, '{}'::jsonb) || $2::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [context.staff.staffId, JSON.stringify({ profilePhotoUri: uploaded.fileUri })] + ); + }); + + const preview = await createPreviewUrl(actorUid, uploaded.fileUri); + return { + staffId: context.staff.staffId, + fileUri: uploaded.fileUri, + signedUrl: preview.signedUrl, + expiresAt: preview.expiresAt, + }; +} + +async function requireDocument(tenantId, documentId, allowedTypes) { + const result = await query( + ` + SELECT id, document_type, name + FROM documents + WHERE tenant_id = $1 + AND id = $2 + AND document_type = ANY($3::text[]) + `, + [tenantId, documentId, allowedTypes] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Document not found for requested upload type', 404, { + documentId, + allowedTypes, + }); + } + return result.rows[0]; +} + +export async function uploadStaffDocument({ actorUid, documentId, file, routeType }) { + const context = await requireStaffContext(actorUid); + const document = await requireDocument( + context.tenant.tenantId, + documentId, + routeType === 'attire' ? ['ATTIRE'] : ['DOCUMENT', 'GOVERNMENT_ID', 'TAX_FORM'] + ); + const uploaded = await uploadActorFile({ + actorUid, + file, + category: routeType, + }); + const verification = await createVerificationJob({ + actorUid, + payload: { + type: routeType === 'attire' ? 'attire' : 'government_id', + subjectType: routeType === 'attire' ? 'attire_item' : 'staff_document', + subjectId: documentId, + fileUri: uploaded.fileUri, + metadata: { + routeType, + documentType: document.document_type, + }, + rules: { + expectedDocumentName: document.name, + }, + }, + }); + + await withTransaction(async (client) => { + await client.query( + ` + INSERT INTO staff_documents ( + tenant_id, + staff_id, + document_id, + file_uri, + status, + verification_job_id, + metadata + ) + VALUES ($1, $2, $3, $4, 'PENDING', $5, $6::jsonb) + ON CONFLICT (staff_id, document_id) DO UPDATE + SET file_uri = EXCLUDED.file_uri, + status = 'PENDING', + verification_job_id = EXCLUDED.verification_job_id, + metadata = COALESCE(staff_documents.metadata, '{}'::jsonb) || EXCLUDED.metadata, + updated_at = NOW() + `, + [ + context.tenant.tenantId, + context.staff.staffId, + document.id, + uploaded.fileUri, + verification.verificationId, + JSON.stringify({ + verificationStatus: verification.status, + routeType, + }), + ] + ); + }); + + const preview = await createPreviewUrl(actorUid, uploaded.fileUri); + return { + documentId: document.id, + documentType: document.document_type, + fileUri: uploaded.fileUri, + signedUrl: preview.signedUrl, + expiresAt: preview.expiresAt, + verification, + }; +} + +export async function uploadCertificate({ actorUid, file, payload }) { + const context = await requireStaffContext(actorUid); + const uploaded = await uploadActorFile({ + actorUid, + file, + category: 'certificate', + }); + const verification = await createVerificationJob({ + actorUid, + payload: { + type: 'certification', + subjectType: 'certificate', + subjectId: payload.certificateType, + fileUri: uploaded.fileUri, + metadata: { + certificateType: payload.certificateType, + name: payload.name, + issuer: payload.issuer || null, + certificateNumber: payload.certificateNumber || null, + }, + rules: { + certificateType: payload.certificateType, + name: payload.name, + }, + }, + }); + + const certificateResult = await withTransaction(async (client) => { + return client.query( + ` + INSERT INTO certificates ( + tenant_id, + staff_id, + certificate_type, + certificate_number, + issued_at, + expires_at, + status, + file_uri, + verification_job_id, + metadata + ) + VALUES ($1, $2, $3, $4, NOW(), $5, 'PENDING', $6, $7, $8::jsonb) + RETURNING id + `, + [ + context.tenant.tenantId, + context.staff.staffId, + payload.certificateType, + payload.certificateNumber || null, + payload.expiresAt || null, + uploaded.fileUri, + verification.verificationId, + JSON.stringify({ + name: payload.name, + issuer: payload.issuer || null, + verificationStatus: verification.status, + }), + ] + ); + }); + + const preview = await createPreviewUrl(actorUid, uploaded.fileUri); + return { + certificateId: certificateResult.rows[0].id, + certificateType: payload.certificateType, + fileUri: uploaded.fileUri, + signedUrl: preview.signedUrl, + expiresAt: preview.expiresAt, + verification, + }; +} + +export async function deleteCertificate({ actorUid, certificateType }) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + DELETE FROM certificates + WHERE tenant_id = $1 + AND staff_id = $2 + AND certificate_type = $3 + RETURNING id + `, + [context.tenant.tenantId, context.staff.staffId, certificateType] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Certificate not found for current staff user', 404, { + certificateType, + }); + } + return { + certificateId: result.rows[0].id, + deleted: true, + }; +} diff --git a/backend/core-api/src/services/verification-jobs.js b/backend/core-api/src/services/verification-jobs.js index 5ffe44bd..ce46679b 100644 --- a/backend/core-api/src/services/verification-jobs.js +++ b/backend/core-api/src/services/verification-jobs.js @@ -1,9 +1,8 @@ -import crypto from 'node:crypto'; import { AppError } from '../lib/errors.js'; +import { isDatabaseConfigured, query, withTransaction } from './db.js'; +import { requireTenantContext } from './actor-context.js'; import { invokeVertexMultimodalModel } from './llm.js'; -const jobs = new Map(); - export const VerificationStatus = Object.freeze({ PENDING: 'PENDING', PROCESSING: 'PROCESSING', @@ -15,82 +14,96 @@ export const VerificationStatus = Object.freeze({ ERROR: 'ERROR', }); -const MACHINE_TERMINAL_STATUSES = new Set([ - VerificationStatus.AUTO_PASS, - VerificationStatus.AUTO_FAIL, - VerificationStatus.NEEDS_REVIEW, - VerificationStatus.ERROR, -]); - const HUMAN_TERMINAL_STATUSES = new Set([ VerificationStatus.APPROVED, VerificationStatus.REJECTED, ]); -function nowIso() { - return new Date().toISOString(); +const memoryVerificationJobs = new Map(); + +function useMemoryStore() { + if (process.env.VERIFICATION_STORE === 'memory') { + return true; + } + return !isDatabaseConfigured() && (process.env.NODE_ENV === 'test' || process.env.AUTH_BYPASS === 'true'); +} + +function nextVerificationId() { + if (typeof crypto?.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `verification_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; +} + +function loadMemoryJob(verificationId) { + const job = memoryVerificationJobs.get(verificationId); + if (!job) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { + verificationId, + }); + } + return job; +} + +async function processVerificationJobInMemory(verificationId) { + const job = memoryVerificationJobs.get(verificationId); + if (!job || job.status !== VerificationStatus.PENDING) { + return; + } + + job.status = VerificationStatus.PROCESSING; + job.updated_at = new Date().toISOString(); + memoryVerificationJobs.set(verificationId, job); + + const workItem = { + id: job.id, + type: job.type, + fileUri: job.file_uri, + subjectType: job.subject_type, + subjectId: job.subject_id, + rules: job.metadata?.rules || {}, + metadata: job.metadata || {}, + }; + + try { + const result = workItem.type === 'attire' + ? await runAttireChecks(workItem) + : await runThirdPartyChecks(workItem, workItem.type); + + const updated = { + ...job, + status: result.status, + confidence: result.confidence, + reasons: result.reasons || [], + extracted: result.extracted || {}, + provider_name: result.provider?.name || null, + provider_reference: result.provider?.reference || null, + updated_at: new Date().toISOString(), + }; + memoryVerificationJobs.set(verificationId, updated); + } catch (error) { + const updated = { + ...job, + status: VerificationStatus.ERROR, + reasons: [error?.message || 'Verification processing failed'], + provider_name: 'verification-worker', + provider_reference: `error:${error?.code || 'unknown'}`, + updated_at: new Date().toISOString(), + }; + memoryVerificationJobs.set(verificationId, updated); + } } function accessMode() { return process.env.VERIFICATION_ACCESS_MODE || 'authenticated'; } -function eventRecord({ fromStatus, toStatus, actorType, actorId, details = {} }) { - return { - id: crypto.randomUUID(), - fromStatus, - toStatus, - actorType, - actorId, - details, - createdAt: nowIso(), - }; +function providerTimeoutMs() { + return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10); } -function toPublicJob(job) { - return { - verificationId: job.id, - type: job.type, - subjectType: job.subjectType, - subjectId: job.subjectId, - fileUri: job.fileUri, - status: job.status, - confidence: job.confidence, - reasons: job.reasons, - extracted: job.extracted, - provider: job.provider, - review: job.review, - createdAt: job.createdAt, - updatedAt: job.updatedAt, - }; -} - -function assertAccess(job, actorUid) { - if (accessMode() === 'authenticated') { - return; - } - if (job.ownerUid !== actorUid) { - throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403); - } -} - -function requireJob(id) { - const job = jobs.get(id); - if (!job) { - throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId: id }); - } - return job; -} - -function normalizeMachineStatus(status) { - if ( - status === VerificationStatus.AUTO_PASS - || status === VerificationStatus.AUTO_FAIL - || status === VerificationStatus.NEEDS_REVIEW - ) { - return status; - } - return VerificationStatus.NEEDS_REVIEW; +function attireModel() { + return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001'; } function clampConfidence(value, fallback = 0.5) { @@ -108,12 +121,89 @@ function asReasonList(reasons, fallback) { return [fallback]; } -function providerTimeoutMs() { - return Number.parseInt(process.env.VERIFICATION_PROVIDER_TIMEOUT_MS || '8000', 10); +function normalizeMachineStatus(status) { + if ( + status === VerificationStatus.AUTO_PASS + || status === VerificationStatus.AUTO_FAIL + || status === VerificationStatus.NEEDS_REVIEW + ) { + return status; + } + return VerificationStatus.NEEDS_REVIEW; } -function attireModel() { - return process.env.VERIFICATION_ATTIRE_MODEL || 'gemini-2.0-flash-lite-001'; +function toPublicJob(row) { + if (!row) return null; + return { + verificationId: row.id, + type: row.type, + subjectType: row.subject_type, + subjectId: row.subject_id, + fileUri: row.file_uri, + status: row.status, + confidence: row.confidence == null ? null : Number(row.confidence), + reasons: Array.isArray(row.reasons) ? row.reasons : [], + extracted: row.extracted || {}, + provider: row.provider_name + ? { + name: row.provider_name, + reference: row.provider_reference || null, + } + : null, + review: row.review || {}, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function assertAccess(row, actorUid) { + if (accessMode() === 'authenticated') { + return; + } + if (row.owner_user_id !== actorUid) { + throw new AppError('FORBIDDEN', 'Not allowed to access this verification', 403); + } +} + +async function loadJob(verificationId) { + const result = await query( + ` + SELECT * + FROM verification_jobs + WHERE id = $1 + `, + [verificationId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { + verificationId, + }); + } + return result.rows[0]; +} + +async function appendVerificationEvent(client, { + verificationJobId, + fromStatus, + toStatus, + actorType, + actorId, + details = {}, +}) { + await client.query( + ` + INSERT INTO verification_events ( + verification_job_id, + from_status, + to_status, + actor_type, + actor_id, + details + ) + VALUES ($1, $2, $3, $4, $5, $6::jsonb) + `, + [verificationJobId, fromStatus, toStatus, actorType, actorId, JSON.stringify(details)] + ); } async function runAttireChecks(job) { @@ -258,47 +348,26 @@ async function runThirdPartyChecks(job, type) { signal: controller.signal, }); - const bodyText = await response.text(); - let body = {}; - try { - body = bodyText ? JSON.parse(bodyText) : {}; - } catch { - body = {}; - } - + const payload = await response.json().catch(() => ({})); if (!response.ok) { - return { - status: VerificationStatus.NEEDS_REVIEW, - confidence: 0.35, - reasons: [`${provider.name} returned ${response.status}`], - extracted: {}, - provider: { - name: provider.name, - reference: body?.reference || null, - }, - }; + throw new Error(payload?.error || payload?.message || `${provider.name} failed`); } return { - status: normalizeMachineStatus(body.status), - confidence: clampConfidence(body.confidence, 0.6), - reasons: asReasonList(body.reasons, `${provider.name} completed check`), - extracted: body.extracted || {}, + status: normalizeMachineStatus(payload.status), + confidence: clampConfidence(payload.confidence, 0.6), + reasons: asReasonList(payload.reasons, `${provider.name} completed`), + extracted: payload.extracted || {}, provider: { name: provider.name, - reference: body.reference || null, + reference: payload.reference || null, }, }; } catch (error) { - const isAbort = error?.name === 'AbortError'; return { status: VerificationStatus.NEEDS_REVIEW, - confidence: 0.3, - reasons: [ - isAbort - ? `${provider.name} timeout, manual review required` - : `${provider.name} unavailable, manual review required`, - ], + confidence: 0.35, + reasons: [error?.message || `${provider.name} unavailable`], extracted: {}, provider: { name: provider.name, @@ -310,201 +379,462 @@ async function runThirdPartyChecks(job, type) { } } -async function runMachineChecks(job) { - if (job.type === 'attire') { - return runAttireChecks(job); - } +async function processVerificationJob(verificationId) { + const startedJob = await withTransaction(async (client) => { + const result = await client.query( + ` + SELECT * + FROM verification_jobs + WHERE id = $1 + FOR UPDATE + `, + [verificationId] + ); - if (job.type === 'government_id') { - return runThirdPartyChecks(job, 'government_id'); - } + if (result.rowCount === 0) { + return null; + } - return runThirdPartyChecks(job, 'certification'); -} + const job = result.rows[0]; + if (job.status !== VerificationStatus.PENDING) { + return null; + } -async function processVerificationJob(id) { - const job = requireJob(id); - if (job.status !== VerificationStatus.PENDING) { + await client.query( + ` + UPDATE verification_jobs + SET status = $2, + updated_at = NOW() + WHERE id = $1 + `, + [verificationId, VerificationStatus.PROCESSING] + ); + + await appendVerificationEvent(client, { + verificationJobId: verificationId, + fromStatus: job.status, + toStatus: VerificationStatus.PROCESSING, + actorType: 'worker', + actorId: 'verification-worker', + }); + + return { + id: verificationId, + type: job.type, + fileUri: job.file_uri, + subjectType: job.subject_type, + subjectId: job.subject_id, + rules: job.metadata?.rules || {}, + metadata: job.metadata || {}, + }; + }); + + if (!startedJob) { return; } - const beforeProcessing = job.status; - job.status = VerificationStatus.PROCESSING; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus: beforeProcessing, - toStatus: VerificationStatus.PROCESSING, - actorType: 'system', - actorId: 'verification-worker', - }) - ); - try { - const outcome = await runMachineChecks(job); - if (!MACHINE_TERMINAL_STATUSES.has(outcome.status)) { - throw new Error(`Invalid machine outcome status: ${outcome.status}`); - } - const fromStatus = job.status; - job.status = outcome.status; - job.confidence = outcome.confidence; - job.reasons = outcome.reasons; - job.extracted = outcome.extracted; - job.provider = outcome.provider; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus, - toStatus: job.status, - actorType: 'system', + const result = startedJob.type === 'attire' + ? await runAttireChecks(startedJob) + : await runThirdPartyChecks(startedJob, startedJob.type); + + await withTransaction(async (client) => { + await client.query( + ` + UPDATE verification_jobs + SET status = $2, + confidence = $3, + reasons = $4::jsonb, + extracted = $5::jsonb, + provider_name = $6, + provider_reference = $7, + updated_at = NOW() + WHERE id = $1 + `, + [ + verificationId, + result.status, + result.confidence, + JSON.stringify(result.reasons || []), + JSON.stringify(result.extracted || {}), + result.provider?.name || null, + result.provider?.reference || null, + ] + ); + + await appendVerificationEvent(client, { + verificationJobId: verificationId, + fromStatus: VerificationStatus.PROCESSING, + toStatus: result.status, + actorType: 'worker', actorId: 'verification-worker', details: { - confidence: job.confidence, - reasons: job.reasons, - provider: job.provider, + confidence: result.confidence, }, - }) - ); + }); + }); } catch (error) { - const fromStatus = job.status; - job.status = VerificationStatus.ERROR; - job.confidence = null; - job.reasons = [error?.message || 'Verification processing failed']; - job.extracted = {}; - job.provider = { - name: 'verification-worker', - reference: null, - }; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus, + await withTransaction(async (client) => { + await client.query( + ` + UPDATE verification_jobs + SET status = $2, + reasons = $3::jsonb, + provider_name = 'verification-worker', + provider_reference = $4, + updated_at = NOW() + WHERE id = $1 + `, + [ + verificationId, + VerificationStatus.ERROR, + JSON.stringify([error?.message || 'Verification processing failed']), + `error:${error?.code || 'unknown'}`, + ] + ); + + await appendVerificationEvent(client, { + verificationJobId: verificationId, + fromStatus: VerificationStatus.PROCESSING, toStatus: VerificationStatus.ERROR, - actorType: 'system', + actorType: 'worker', actorId: 'verification-worker', details: { error: error?.message || 'Verification processing failed', }, - }) - ); - } -} - -function queueVerificationProcessing(id) { - setTimeout(() => { - processVerificationJob(id).catch(() => {}); - }, 0); -} - -export function createVerificationJob({ actorUid, payload }) { - const now = nowIso(); - const id = `ver_${crypto.randomUUID()}`; - const job = { - id, - type: payload.type, - subjectType: payload.subjectType || null, - subjectId: payload.subjectId || null, - ownerUid: actorUid, - fileUri: payload.fileUri, - rules: payload.rules || {}, - metadata: payload.metadata || {}, - status: VerificationStatus.PENDING, - confidence: null, - reasons: [], - extracted: {}, - provider: null, - review: null, - createdAt: now, - updatedAt: now, - events: [ - eventRecord({ - fromStatus: null, - toStatus: VerificationStatus.PENDING, - actorType: 'system', - actorId: actorUid, - }), - ], - }; - jobs.set(id, job); - queueVerificationProcessing(id); - return toPublicJob(job); -} - -export function getVerificationJob(verificationId, actorUid) { - const job = requireJob(verificationId); - assertAccess(job, actorUid); - return toPublicJob(job); -} - -export function reviewVerificationJob(verificationId, actorUid, review) { - const job = requireJob(verificationId); - assertAccess(job, actorUid); - - if (HUMAN_TERMINAL_STATUSES.has(job.status)) { - throw new AppError('CONFLICT', 'Verification already finalized', 409, { - verificationId, - status: job.status, + }); }); } +} - const fromStatus = job.status; - job.status = review.decision; - job.review = { - decision: review.decision, - reviewedBy: actorUid, - reviewedAt: nowIso(), - note: review.note || '', - reasonCode: review.reasonCode || 'MANUAL_REVIEW', - }; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus, - toStatus: job.status, +function queueVerificationProcessing(verificationId) { + setImmediate(() => { + const worker = useMemoryStore() ? processVerificationJobInMemory : processVerificationJob; + worker(verificationId).catch(() => {}); + }); +} + +export async function createVerificationJob({ actorUid, payload }) { + if (useMemoryStore()) { + const timestamp = new Date().toISOString(); + const created = { + id: nextVerificationId(), + tenant_id: null, + staff_id: null, + owner_user_id: actorUid, + type: payload.type, + subject_type: payload.subjectType || null, + subject_id: payload.subjectId || null, + file_uri: payload.fileUri, + status: VerificationStatus.PENDING, + confidence: null, + reasons: [], + extracted: {}, + provider_name: null, + provider_reference: null, + review: {}, + metadata: { + ...(payload.metadata || {}), + rules: payload.rules || {}, + }, + created_at: timestamp, + updated_at: timestamp, + }; + memoryVerificationJobs.set(created.id, created); + queueVerificationProcessing(created.id); + return toPublicJob(created); + } + + const context = await requireTenantContext(actorUid); + const created = await withTransaction(async (client) => { + const result = await client.query( + ` + INSERT INTO verification_jobs ( + tenant_id, + staff_id, + document_id, + owner_user_id, + type, + subject_type, + subject_id, + file_uri, + status, + reasons, + extracted, + review, + metadata + ) + VALUES ( + $1, + $2, + NULL, + $3, + $4, + $5, + $6, + $7, + 'PENDING', + '[]'::jsonb, + '{}'::jsonb, + '{}'::jsonb, + $8::jsonb + ) + RETURNING * + `, + [ + context.tenant.tenantId, + context.staff?.staffId || null, + actorUid, + payload.type, + payload.subjectType || null, + payload.subjectId || null, + payload.fileUri, + JSON.stringify({ + ...(payload.metadata || {}), + rules: payload.rules || {}, + }), + ] + ); + + await appendVerificationEvent(client, { + verificationJobId: result.rows[0].id, + fromStatus: null, + toStatus: VerificationStatus.PENDING, + actorType: 'system', + actorId: actorUid, + }); + + return result.rows[0]; + }); + + queueVerificationProcessing(created.id); + return toPublicJob(created); +} + +export async function getVerificationJob(verificationId, actorUid) { + if (useMemoryStore()) { + const job = loadMemoryJob(verificationId); + assertAccess(job, actorUid); + return toPublicJob(job); + } + + const job = await loadJob(verificationId); + assertAccess(job, actorUid); + return toPublicJob(job); +} + +export async function reviewVerificationJob(verificationId, actorUid, review) { + if (useMemoryStore()) { + const job = loadMemoryJob(verificationId); + assertAccess(job, actorUid); + if (HUMAN_TERMINAL_STATUSES.has(job.status)) { + throw new AppError('CONFLICT', 'Verification already finalized', 409, { + verificationId, + status: job.status, + }); + } + + const reviewPayload = { + decision: review.decision, + reviewedBy: actorUid, + reviewedAt: new Date().toISOString(), + note: review.note || '', + reasonCode: review.reasonCode || 'MANUAL_REVIEW', + }; + + const updated = { + ...job, + status: review.decision, + review: reviewPayload, + updated_at: new Date().toISOString(), + }; + memoryVerificationJobs.set(verificationId, updated); + return toPublicJob(updated); + } + + const context = await requireTenantContext(actorUid); + const updated = await withTransaction(async (client) => { + const result = await client.query( + ` + SELECT * + FROM verification_jobs + WHERE id = $1 + FOR UPDATE + `, + [verificationId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId }); + } + + const job = result.rows[0]; + assertAccess(job, actorUid); + if (HUMAN_TERMINAL_STATUSES.has(job.status)) { + throw new AppError('CONFLICT', 'Verification already finalized', 409, { + verificationId, + status: job.status, + }); + } + + const reviewPayload = { + decision: review.decision, + reviewedBy: actorUid, + reviewedAt: new Date().toISOString(), + note: review.note || '', + reasonCode: review.reasonCode || 'MANUAL_REVIEW', + }; + + await client.query( + ` + UPDATE verification_jobs + SET status = $2, + review = $3::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [verificationId, review.decision, JSON.stringify(reviewPayload)] + ); + + await client.query( + ` + INSERT INTO verification_reviews ( + verification_job_id, + reviewer_user_id, + decision, + note, + reason_code + ) + VALUES ($1, $2, $3, $4, $5) + `, + [verificationId, actorUid, review.decision, review.note || null, review.reasonCode || 'MANUAL_REVIEW'] + ); + + await appendVerificationEvent(client, { + verificationJobId: verificationId, + fromStatus: job.status, + toStatus: review.decision, actorType: 'reviewer', actorId: actorUid, details: { - reasonCode: job.review.reasonCode, + reasonCode: review.reasonCode || 'MANUAL_REVIEW', }, - }) - ); + }); - return toPublicJob(job); + return { + ...job, + status: review.decision, + review: reviewPayload, + updated_at: new Date().toISOString(), + }; + }); + + void context; + return toPublicJob(updated); } -export function retryVerificationJob(verificationId, actorUid) { - const job = requireJob(verificationId); - assertAccess(job, actorUid); +export async function retryVerificationJob(verificationId, actorUid) { + if (useMemoryStore()) { + const job = loadMemoryJob(verificationId); + assertAccess(job, actorUid); + if (job.status === VerificationStatus.PROCESSING) { + throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { + verificationId, + }); + } - if (job.status === VerificationStatus.PROCESSING) { - throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { - verificationId, - }); + const updated = { + ...job, + status: VerificationStatus.PENDING, + confidence: null, + reasons: [], + extracted: {}, + provider_name: null, + provider_reference: null, + review: {}, + updated_at: new Date().toISOString(), + }; + memoryVerificationJobs.set(verificationId, updated); + queueVerificationProcessing(verificationId); + return toPublicJob(updated); } - const fromStatus = job.status; - job.status = VerificationStatus.PENDING; - job.confidence = null; - job.reasons = []; - job.extracted = {}; - job.provider = null; - job.review = null; - job.updatedAt = nowIso(); - job.events.push( - eventRecord({ - fromStatus, + const updated = await withTransaction(async (client) => { + const result = await client.query( + ` + SELECT * + FROM verification_jobs + WHERE id = $1 + FOR UPDATE + `, + [verificationId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Verification not found', 404, { verificationId }); + } + + const job = result.rows[0]; + assertAccess(job, actorUid); + if (job.status === VerificationStatus.PROCESSING) { + throw new AppError('CONFLICT', 'Cannot retry while verification is processing', 409, { + verificationId, + }); + } + + await client.query( + ` + UPDATE verification_jobs + SET status = $2, + confidence = NULL, + reasons = '[]'::jsonb, + extracted = '{}'::jsonb, + provider_name = NULL, + provider_reference = NULL, + review = '{}'::jsonb, + updated_at = NOW() + WHERE id = $1 + `, + [verificationId, VerificationStatus.PENDING] + ); + + await appendVerificationEvent(client, { + verificationJobId: verificationId, + fromStatus: job.status, toStatus: VerificationStatus.PENDING, actorType: 'reviewer', actorId: actorUid, details: { retried: true, }, - }) - ); + }); + + return { + ...job, + status: VerificationStatus.PENDING, + confidence: null, + reasons: [], + extracted: {}, + provider_name: null, + provider_reference: null, + review: {}, + updated_at: new Date().toISOString(), + }; + }); + queueVerificationProcessing(verificationId); - return toPublicJob(job); + return toPublicJob(updated); } -export function __resetVerificationJobsForTests() { - jobs.clear(); +export async function __resetVerificationJobsForTests() { + if (process.env.NODE_ENV !== 'test' && process.env.AUTH_BYPASS !== 'true') { + return; + } + memoryVerificationJobs.clear(); + try { + await query('DELETE FROM verification_reviews'); + await query('DELETE FROM verification_events'); + await query('DELETE FROM verification_jobs'); + } catch { + // Intentionally ignore when tests run without a configured database. + } } diff --git a/backend/core-api/test/app.test.js b/backend/core-api/test/app.test.js index c3e50de5..d6613a07 100644 --- a/backend/core-api/test/app.test.js +++ b/backend/core-api/test/app.test.js @@ -5,7 +5,7 @@ import { createApp } from '../src/app.js'; import { __resetLlmRateLimitForTests } from '../src/services/llm-rate-limit.js'; import { __resetVerificationJobsForTests } from '../src/services/verification-jobs.js'; -beforeEach(() => { +beforeEach(async () => { process.env.AUTH_BYPASS = 'true'; process.env.LLM_MOCK = 'true'; process.env.SIGNED_URL_MOCK = 'true'; @@ -15,8 +15,9 @@ beforeEach(() => { process.env.VERIFICATION_REQUIRE_FILE_EXISTS = 'false'; process.env.VERIFICATION_ACCESS_MODE = 'authenticated'; process.env.VERIFICATION_ATTIRE_PROVIDER = 'mock'; + process.env.VERIFICATION_STORE = 'memory'; __resetLlmRateLimitForTests(); - __resetVerificationJobsForTests(); + await __resetVerificationJobsForTests(); }); async function waitForMachineStatus(app, verificationId, maxAttempts = 30) { @@ -49,6 +50,22 @@ test('GET /healthz returns healthy response', async () => { assert.equal(typeof res.headers['x-request-id'], 'string'); }); +test('GET /readyz reports database not configured when env is absent', async () => { + delete process.env.DATABASE_URL; + delete process.env.DB_HOST; + delete process.env.DB_NAME; + delete process.env.DB_USER; + delete process.env.DB_PASSWORD; + delete process.env.INSTANCE_CONNECTION_NAME; + delete process.env.VERIFICATION_STORE; + + const app = createApp(); + const res = await request(app).get('/readyz'); + + assert.equal(res.status, 503); + assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); +}); + test('POST /core/create-signed-url requires auth', async () => { process.env.AUTH_BYPASS = 'false'; const app = createApp(); diff --git a/backend/dataconnect/functions/clean.gql b/backend/dataconnect/functions/clean.gql index df8e84d5..c5ba6739 100644 --- a/backend/dataconnect/functions/clean.gql +++ b/backend/dataconnect/functions/clean.gql @@ -16,15 +16,18 @@ mutation unseedAll @auth(level: USER) { invoiceTemplate_deleteMany(all: true) customRateCard_deleteMany(all: true) vendorRate_deleteMany(all: true) - vendorBenefitPlan_deleteMany(all: true) + benefitsData_deleteMany(all: true) + workforce_deleteMany(all: true) staffCourse_deleteMany(all: true) staffDocument_deleteMany(all: true) + staffAttire_deleteMany(all: true) staffRole_deleteMany(all: true) staffAvailability_deleteMany(all: true) staffAvailabilityStats_deleteMany(all: true) emergencyContact_deleteMany(all: true) taxForm_deleteMany(all: true) certificate_deleteMany(all: true) + vendorBenefitPlan_deleteMany(all: true) # ---------------------------------- # Tasks / Shifts / Orders @@ -33,6 +36,7 @@ mutation unseedAll @auth(level: USER) { shiftRole_deleteMany(all: true) shift_deleteMany(all: true) order_deleteMany(all: true) + costCenter_deleteMany(all: true) # ---------------------------------- # Teams / Hubs / Org @@ -52,7 +56,6 @@ mutation unseedAll @auth(level: USER) { level_deleteMany(all: true) course_deleteMany(all: true) faqData_deleteMany(all: true) - benefitsData_deleteMany(all: true) attireOption_deleteMany(all: true) document_deleteMany(all: true) diff --git a/backend/dataconnect/functions/seed.gql b/backend/dataconnect/functions/seed.gql index 3a5f65ea..a87c3366 100644 --- a/backend/dataconnect/functions/seed.gql +++ b/backend/dataconnect/functions/seed.gql @@ -493,6 +493,208 @@ mutation seedAll @transaction { } ) + # Workforce assignments + workforce_1: workforce_insert( + data: { + id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1001" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57" + workforceNumber: "WF-1001" + employmentType: W2 + status: ACTIVE + createdBy: "seed-script" + } + ) + workforce_2: workforce_insert( + data: { + id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1002" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + staffId: "9631581a-1601-4e06-8e5e-600e9f305bcf" + workforceNumber: "WF-1002" + employmentType: W1099 + status: ACTIVE + createdBy: "seed-script" + } + ) + workforce_3: workforce_insert( + data: { + id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1003" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + staffId: "2b678a6d-b8cd-4d5e-95ae-f35e4569f92c" + workforceNumber: "WF-1003" + employmentType: W2 + status: ACTIVE + createdBy: "seed-script" + } + ) + workforce_4: workforce_insert( + data: { + id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1004" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + staffId: "d62605f9-366d-42c5-8f3b-f276c0d27ea3" + workforceNumber: "WF-1004" + employmentType: W2 + status: ACTIVE + createdBy: "seed-script" + } + ) + workforce_5: workforce_insert( + data: { + id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1005" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + staffId: "c6428f90-9c29-4e5c-b362-dc67a9a8cbba" + workforceNumber: "WF-1005" + employmentType: W1099 + status: ACTIVE + createdBy: "seed-script" + } + ) + workforce_6: workforce_insert( + data: { + id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1006" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + staffId: "56d7178c-f4ab-4c50-9b1f-d6efe25ba50b" + workforceNumber: "WF-1006" + employmentType: CONTRACT + status: ACTIVE + createdBy: "seed-script" + } + ) + workforce_7: workforce_insert( + data: { + id: "7f1d6cf2-4a26-4a3e-9d1b-1d68ca2a1007" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + workforceNumber: "WF-1007" + employmentType: W2 + status: ACTIVE + createdBy: "seed-script" + } + ) + + # Benefit plans + benefit_plan_1: vendorBenefitPlan_insert( + data: { + id: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01001" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + title: "Paid Time Off" + description: "Annual paid time off allowance." + requestLabel: "Request PTO" + total: 80 + isActive: true + } + ) + benefit_plan_2: vendorBenefitPlan_insert( + data: { + id: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01002" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + title: "Sick Leave" + description: "Paid sick leave balance." + requestLabel: "Request Sick Leave" + total: 40 + isActive: true + } + ) + benefit_plan_3: vendorBenefitPlan_insert( + data: { + id: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01003" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + title: "Training Hours" + description: "Vendor-sponsored training time." + requestLabel: "Request Training Hours" + total: 24 + isActive: true + } + ) + + # Benefits balances (remaining hours) + benefits_data_1: benefitsData_insert( + data: { + id: "aa8bf762-141e-4c69-ae15-7c5416fd1101" + vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01001" + staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57" + current: 52 + } + ) + benefits_data_2: benefitsData_insert( + data: { + id: "aa8bf762-141e-4c69-ae15-7c5416fd1102" + vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01002" + staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57" + current: 30 + } + ) + benefits_data_3: benefitsData_insert( + data: { + id: "aa8bf762-141e-4c69-ae15-7c5416fd1103" + vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01003" + staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57" + current: 16 + } + ) + benefits_data_4: benefitsData_insert( + data: { + id: "aa8bf762-141e-4c69-ae15-7c5416fd1104" + vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01001" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + current: 64 + } + ) + benefits_data_5: benefitsData_insert( + data: { + id: "aa8bf762-141e-4c69-ae15-7c5416fd1105" + vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01002" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + current: 36 + } + ) + benefits_data_6: benefitsData_insert( + data: { + id: "aa8bf762-141e-4c69-ae15-7c5416fd1106" + vendorBenefitPlanId: "2d8f7d4b-1f90-4d8b-8b9d-9200d8f01003" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + current: 20 + } + ) + + # Bank accounts (client + staff) + account_1: account_insert( + data: { + id: "ed6fd954-3f25-4ab7-b44f-2f03f1ca5101" + bank: "Bank of America" + type: CHECKING + last4: "4455" + isPrimary: true + accountNumber: "883104455" + routeNumber: "121000358" + ownerId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + } + ) + account_2: account_insert( + data: { + id: "ed6fd954-3f25-4ab7-b44f-2f03f1ca5102" + bank: "Chase" + type: CHECKING + last4: "3301" + isPrimary: true + accountNumber: "009813301" + routeNumber: "322271627" + ownerId: "633df3ce-b92c-473f-90d8-38dd027fdf57" + } + ) + account_3: account_insert( + data: { + id: "ed6fd954-3f25-4ab7-b44f-2f03f1ca5103" + bank: "Wells Fargo" + type: SAVINGS + last4: "7812" + isPrimary: true + accountNumber: "114927812" + routeNumber: "121042882" + ownerId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + } + ) + # Documents document_1: document_insert( data: { @@ -575,6 +777,41 @@ mutation seedAll @transaction { verificationId: "verif_staff3_id_001" } ) + staff_document_5: staffDocument_insert( + data: { + id: "bf01f474-2f2d-40f5-8ca7-0b6a1e52dd8a" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + staffName: "Test Staff" + documentId: "9fd8b1bb-63b4-4480-a53e-c65ae5f03ea8" + status: VERIFIED + documentUrl: "https://storage.googleapis.com/krow-workforce-dev/docs/staff-7/w4.pdf" + verificationId: "verif_staff7_w4_001" + verifiedAt: "2026-02-20T17:15:00Z" + } + ) + staff_document_6: staffDocument_insert( + data: { + id: "bf01f474-2f2d-40f5-8ca7-0b6a1e52dd8b" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + staffName: "Test Staff" + documentId: "f3389b80-9407-45ca-bdbe-6aeb873f2f5d" + status: VERIFIED + documentUrl: "https://storage.googleapis.com/krow-workforce-dev/docs/staff-7/direct-deposit.pdf" + verificationId: "verif_staff7_dd_001" + verifiedAt: "2026-02-20T18:00:00Z" + } + ) + staff_document_7: staffDocument_insert( + data: { + id: "bf01f474-2f2d-40f5-8ca7-0b6a1e52dd8c" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + staffName: "Test Staff" + documentId: "2eb0f5e0-b5f7-4ec2-8994-ae5b12ec8f57" + status: PENDING + documentUrl: "https://storage.googleapis.com/krow-workforce-dev/docs/staff-7/id-copy.png" + verificationId: "verif_staff7_id_001" + } + ) # Certificates certificate_1: certificate_insert( @@ -618,6 +855,34 @@ mutation seedAll @transaction { certificateNumber: "RBS-STAFF2-1103" } ) + certificate_4: certificate_insert( + data: { + id: "67b9ec44-6f9b-4f3a-9c8d-23f370883a90" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + certificationType: BACKGROUND_CHECK + name: "Background Check Clearance" + status: CURRENT + issuer: "Checkr" + fileUrl: "https://storage.googleapis.com/krow-workforce-dev/certs/staff-7/background.pdf" + validationStatus: APPROVED + certificateNumber: "BG-STAFF7-2026" + expiry: "2027-02-20T00:00:00Z" + } + ) + certificate_5: certificate_insert( + data: { + id: "67b9ec44-6f9b-4f3a-9c8d-23f370883a91" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + certificationType: FOOD_HANDLER + name: "Food Handler Card" + status: CURRENT + issuer: "ServSafe" + fileUrl: "https://storage.googleapis.com/krow-workforce-dev/certs/staff-7/food-handler.pdf" + validationStatus: AI_VERIFIED + certificateNumber: "FH-STAFF7-2204" + expiry: "2026-12-30T00:00:00Z" + } + ) # Orders (20 total) order_01: order_insert( @@ -900,6 +1165,90 @@ mutation seedAll @transaction { total: 224 } ) + order_21: order_insert( + data: { + id: "f201e540-70cd-4e49-b3b1-4c85df9a2101" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderType: ONE_TIME + status: PARTIAL_STAFFED + eventName: "Client Demo Breakfast Support" + teamHubId: "22a0b119-e6dc-4011-9043-d857cd4c12f3" + date: "2026-03-04T05:00:00Z" + requested: 2 + total: 320 + } + ) + order_22: order_insert( + data: { + id: "f201e540-70cd-4e49-b3b1-4c85df9a2102" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderType: ONE_TIME + status: PARTIAL_STAFFED + eventName: "Conference Lounge Coverage" + teamHubId: "9c8eb9c6-c186-4d55-877e-35be852c3e86" + date: "2026-03-05T05:00:00Z" + requested: 3 + total: 624 + } + ) + order_23: order_insert( + data: { + id: "f201e540-70cd-4e49-b3b1-4c85df9a2103" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderType: ONE_TIME + status: FULLY_STAFFED + eventName: "Executive Lunch Prep" + teamHubId: "75c70d83-1680-4b28-ab61-2fe64a74fc5f" + date: "2026-03-06T05:00:00Z" + requested: 2 + total: 288 + } + ) + order_24: order_insert( + data: { + id: "f201e540-70cd-4e49-b3b1-4c85df9a2104" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderType: ONE_TIME + status: COMPLETED + eventName: "Late Shift Cleanup" + teamHubId: "22a0b119-e6dc-4011-9043-d857cd4c12f3" + date: "2026-03-03T05:00:00Z" + requested: 1 + total: 208 + } + ) + order_25: order_insert( + data: { + id: "f201e540-70cd-4e49-b3b1-4c85df9a2105" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderType: ONE_TIME + status: POSTED + eventName: "Sunday Brunch Standby" + teamHubId: "9c8eb9c6-c186-4d55-877e-35be852c3e86" + date: "2026-03-08T05:00:00Z" + requested: 1 + total: 120 + } + ) + order_26: order_insert( + data: { + id: "f201e540-70cd-4e49-b3b1-4c85df9a2106" + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderType: ONE_TIME + status: FULLY_STAFFED + eventName: "Security Night Rotation" + teamHubId: "75c70d83-1680-4b28-ab61-2fe64a74fc5f" + date: "2026-03-10T05:00:00Z" + requested: 1 + total: 280 + } + ) # Shifts (1 per order) shift_01: shift_insert( @@ -1362,6 +1711,148 @@ mutation seedAll @transaction { filled: 0 } ) + shift_21: shift_insert( + data: { + id: "ea729213-1652-4e4b-95cb-a7d5c1a1e301" + title: "Client Demo Breakfast Support Shift" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2101" + date: "2026-03-04T05:00:00Z" + startTime: "2026-03-04T14:00:00Z" + endTime: "2026-03-04T22:00:00Z" + hours: 8 + cost: 320 + locationAddress: "5000 San Jose Street, Granada Hills, CA, USA" + city: "Los Angeles" + state: "CA" + street: "San Jose Street" + country: "US" + placeId: "Eiw1MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw" + latitude: 34.2611486 + longitude: -118.5010287 + status: IN_PROGRESS + workersNeeded: 2 + filled: 1 + filledAt: "2026-03-03T20:00:00Z" + } + ) + shift_22: shift_insert( + data: { + id: "ea729213-1652-4e4b-95cb-a7d5c1a1e302" + title: "Conference Lounge Coverage Shift" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2102" + date: "2026-03-05T05:00:00Z" + startTime: "2026-03-05T15:00:00Z" + endTime: "2026-03-05T23:00:00Z" + hours: 8 + cost: 624 + locationAddress: "4000 San Jose Street, Granada Hills, CA, USA" + city: "Los Angeles" + state: "CA" + street: "San Jose Street" + country: "US" + placeId: "Eiw0MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw" + latitude: 34.2611486 + longitude: -118.5010287 + status: OPEN + workersNeeded: 3 + filled: 2 + } + ) + shift_23: shift_insert( + data: { + id: "ea729213-1652-4e4b-95cb-a7d5c1a1e303" + title: "Executive Lunch Prep Shift" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2103" + date: "2026-03-06T05:00:00Z" + startTime: "2026-03-06T16:00:00Z" + endTime: "2026-03-06T22:00:00Z" + hours: 6 + cost: 288 + locationAddress: "6800 San Jose Street, Granada Hills, CA, USA" + city: "Los Angeles" + state: "CA" + street: "San Jose Street" + country: "US" + placeId: "Eiw2ODAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw" + latitude: 34.2611486 + longitude: -118.5010287 + status: FILLED + workersNeeded: 2 + filled: 2 + filledAt: "2026-03-04T18:10:00Z" + } + ) + shift_24: shift_insert( + data: { + id: "ea729213-1652-4e4b-95cb-a7d5c1a1e304" + title: "Late Shift Cleanup Shift" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2104" + date: "2026-03-03T05:00:00Z" + startTime: "2026-03-03T14:00:00Z" + endTime: "2026-03-03T22:00:00Z" + hours: 8 + cost: 208 + locationAddress: "5000 San Jose Street, Granada Hills, CA, USA" + city: "Los Angeles" + state: "CA" + street: "San Jose Street" + country: "US" + placeId: "Eiw1MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw" + latitude: 34.2611486 + longitude: -118.5010287 + status: COMPLETED + workersNeeded: 1 + filled: 1 + filledAt: "2026-03-02T22:30:00Z" + } + ) + shift_25: shift_insert( + data: { + id: "ea729213-1652-4e4b-95cb-a7d5c1a1e305" + title: "Sunday Brunch Standby Shift" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2105" + date: "2026-03-08T05:00:00Z" + startTime: "2026-03-08T15:00:00Z" + endTime: "2026-03-08T20:00:00Z" + hours: 5 + cost: 120 + locationAddress: "4000 San Jose Street, Granada Hills, CA, USA" + city: "Los Angeles" + state: "CA" + street: "San Jose Street" + country: "US" + placeId: "Eiw0MDAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw" + latitude: 34.2611486 + longitude: -118.5010287 + status: OPEN + workersNeeded: 1 + filled: 0 + } + ) + shift_26: shift_insert( + data: { + id: "ea729213-1652-4e4b-95cb-a7d5c1a1e306" + title: "Security Night Rotation Shift" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2106" + date: "2026-03-10T05:00:00Z" + startTime: "2026-03-10T14:00:00Z" + endTime: "2026-03-11T00:00:00Z" + hours: 10 + cost: 280 + locationAddress: "6800 San Jose Street, Granada Hills, CA, USA" + city: "Los Angeles" + state: "CA" + street: "San Jose Street" + country: "US" + placeId: "Eiw2ODAwIFNhbiBKb3NlIFN0cmVldCwgR3JhbmFkYSBIaWxscywgQ0EsIFVTQSIuKiwKFAoSCYNJZBTdmsKAEddGOfBj8LvTEhQKEglnNXI0zZrCgBEjR6om62lcVw" + latitude: 34.2611486 + longitude: -118.5010287 + status: FILLED + workersNeeded: 1 + filled: 1 + filledAt: "2026-03-09T19:00:00Z" + } + ) # Shift Roles (1 per shift) shift_role_01: shiftRole_insert( @@ -1644,6 +2135,90 @@ mutation seedAll @transaction { totalValue: 224 } ) + shift_role_21: shiftRole_insert( + data: { + id: "360616bf-8083-4dff-8d22-82380304d901" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e301" + roleId: "73fdb09b-ecbd-402e-8eb4-e7d79237d017" + count: 2 + assigned: 1 + startTime: "2026-03-04T14:00:00Z" + endTime: "2026-03-04T22:00:00Z" + hours: 8 + breakType: MIN_30 + totalValue: 320 + } + ) + shift_role_22: shiftRole_insert( + data: { + id: "360616bf-8083-4dff-8d22-82380304d902" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302" + roleId: "7de956ce-743b-4271-b826-73313a5f07f5" + count: 3 + assigned: 2 + startTime: "2026-03-05T15:00:00Z" + endTime: "2026-03-05T23:00:00Z" + hours: 8 + breakType: MIN_30 + totalValue: 624 + } + ) + shift_role_23: shiftRole_insert( + data: { + id: "360616bf-8083-4dff-8d22-82380304d903" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303" + roleId: "e51f3553-f2ee-400b-91e6-92b534239697" + count: 2 + assigned: 2 + startTime: "2026-03-06T16:00:00Z" + endTime: "2026-03-06T22:00:00Z" + hours: 6 + breakType: MIN_15 + totalValue: 288 + } + ) + shift_role_24: shiftRole_insert( + data: { + id: "360616bf-8083-4dff-8d22-82380304d904" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e304" + roleId: "7de956ce-743b-4271-b826-73313a5f07f5" + count: 1 + assigned: 1 + startTime: "2026-03-03T14:00:00Z" + endTime: "2026-03-03T22:00:00Z" + hours: 8 + breakType: MIN_30 + totalValue: 208 + } + ) + shift_role_25: shiftRole_insert( + data: { + id: "360616bf-8083-4dff-8d22-82380304d905" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e305" + roleId: "e51f3553-f2ee-400b-91e6-92b534239697" + count: 1 + assigned: 0 + startTime: "2026-03-08T15:00:00Z" + endTime: "2026-03-08T20:00:00Z" + hours: 5 + breakType: MIN_15 + totalValue: 120 + } + ) + shift_role_26: shiftRole_insert( + data: { + id: "360616bf-8083-4dff-8d22-82380304d906" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e306" + roleId: "67ab1dcb-5b54-4dd9-aeb5-9cc58bdda0ed" + count: 1 + assigned: 1 + startTime: "2026-03-10T14:00:00Z" + endTime: "2026-03-11T00:00:00Z" + hours: 10 + breakType: MIN_30 + totalValue: 280 + } + ) # Applications application_01: application_insert( @@ -1836,6 +2411,80 @@ mutation seedAll @transaction { origin: STAFF } ) + application_20: application_insert( + data: { + id: "b8c4b723-346d-4bcd-9667-35944ba5dbbe" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e301" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + roleId: "73fdb09b-ecbd-402e-8eb4-e7d79237d017" + status: CHECKED_IN + checkInTime: "2026-03-04T14:02:00Z" + origin: STAFF + } + ) + application_21: application_insert( + data: { + id: "b8c4b723-346d-4bcd-9667-35944ba5dbbf" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e304" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + roleId: "7de956ce-743b-4271-b826-73313a5f07f5" + status: COMPLETED + checkInTime: "2026-03-03T14:05:00Z" + checkOutTime: "2026-03-03T22:01:00Z" + origin: STAFF + } + ) + application_22: application_insert( + data: { + id: "b8c4b723-346d-4bcd-9667-35944ba5dbc0" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303" + staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57" + roleId: "e51f3553-f2ee-400b-91e6-92b534239697" + status: CONFIRMED + origin: STAFF + } + ) + application_23: application_insert( + data: { + id: "b8c4b723-346d-4bcd-9667-35944ba5dbc1" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302" + staffId: "9631581a-1601-4e06-8e5e-600e9f305bcf" + roleId: "7de956ce-743b-4271-b826-73313a5f07f5" + status: LATE + checkInTime: "2026-03-05T15:25:00Z" + origin: STAFF + } + ) + application_24: application_insert( + data: { + id: "b8c4b723-346d-4bcd-9667-35944ba5dbc2" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302" + staffId: "2b678a6d-b8cd-4d5e-95ae-f35e4569f92c" + roleId: "7de956ce-743b-4271-b826-73313a5f07f5" + status: NO_SHOW + origin: STAFF + } + ) + application_25: application_insert( + data: { + id: "b8c4b723-346d-4bcd-9667-35944ba5dbc3" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e306" + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + roleId: "67ab1dcb-5b54-4dd9-aeb5-9cc58bdda0ed" + status: CONFIRMED + origin: STAFF + } + ) + application_26: application_insert( + data: { + id: "b8c4b723-346d-4bcd-9667-35944ba5dbc4" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303" + staffId: "56d7178c-f4ab-4c50-9b1f-d6efe25ba50b" + roleId: "e51f3553-f2ee-400b-91e6-92b534239697" + status: CONFIRMED + origin: STAFF + } + ) # Invoices (for completed orders) invoice_01: invoice_insert( @@ -2030,6 +2679,91 @@ mutation seedAll @transaction { chargesCount: 1 } ) + invoice_13: invoice_insert( + data: { + id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f1" + status: PENDING + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2101" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e301" + paymentTerms: NET_30 + invoiceNumber: "INV-2026-0013" + issueDate: "2026-03-04T05:00:00Z" + dueDate: "2026-04-03T05:00:00Z" + amount: 320 + staffCount: 2 + chargesCount: 1 + } + ) + invoice_14: invoice_insert( + data: { + id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f2" + status: OVERDUE + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2102" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e302" + paymentTerms: NET_30 + invoiceNumber: "INV-2026-0014" + issueDate: "2026-02-20T05:00:00Z" + dueDate: "2026-03-01T05:00:00Z" + amount: 624 + staffCount: 3 + chargesCount: 1 + } + ) + invoice_15: invoice_insert( + data: { + id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f3" + status: APPROVED + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2103" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e303" + paymentTerms: NET_30 + invoiceNumber: "INV-2026-0015" + issueDate: "2026-03-06T05:00:00Z" + dueDate: "2026-04-05T05:00:00Z" + amount: 288 + staffCount: 2 + chargesCount: 1 + } + ) + invoice_16: invoice_insert( + data: { + id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f4" + status: PAID + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2104" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e304" + paymentTerms: NET_30 + invoiceNumber: "INV-2026-0016" + issueDate: "2026-03-03T05:00:00Z" + dueDate: "2026-04-02T05:00:00Z" + amount: 208 + staffCount: 1 + chargesCount: 1 + } + ) + invoice_17: invoice_insert( + data: { + id: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f5" + status: PENDING_REVIEW + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + businessId: "ef69e942-d6e5-48e5-a8bc-69d3faa63b2f" + orderId: "f201e540-70cd-4e49-b3b1-4c85df9a2106" + shiftId: "ea729213-1652-4e4b-95cb-a7d5c1a1e306" + paymentTerms: NET_30 + invoiceNumber: "INV-2026-0017" + issueDate: "2026-03-10T05:00:00Z" + dueDate: "2026-04-09T05:00:00Z" + amount: 280 + staffCount: 1 + chargesCount: 1 + } + ) # Recent Payments (only for PAID invoices) recent_payment_01: recentPayment_insert( @@ -2062,6 +2796,46 @@ mutation seedAll @transaction { invoiceId: "ba0529be-7906-417f-8ec7-c866d0633fee" } ) + recent_payment_04: recentPayment_insert( + data: { + id: "4d45192e-34fe-4e07-a4f9-708e7591a9b6" + workedTime: "8h" + status: PAID + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbbf" + invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f4" + } + ) + recent_payment_05: recentPayment_insert( + data: { + id: "4d45192e-34fe-4e07-a4f9-708e7591a9b7" + workedTime: "8h" + status: PENDING + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbbe" + invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f1" + } + ) + recent_payment_06: recentPayment_insert( + data: { + id: "4d45192e-34fe-4e07-a4f9-708e7591a9b8" + workedTime: "6h" + status: PENDING + staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57" + applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbc0" + invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f3" + } + ) + recent_payment_07: recentPayment_insert( + data: { + id: "4d45192e-34fe-4e07-a4f9-708e7591a9b9" + workedTime: "6h" + status: PENDING + staffId: "56d7178c-f4ab-4c50-9b1f-d6efe25ba50b" + applicationId: "b8c4b723-346d-4bcd-9667-35944ba5dbc4" + invoiceId: "c23f3ed2-7fa1-43f5-88e9-4227e34cb5f3" + } + ) # Attire Options (Required) attire_1: attireOption_insert( @@ -2220,5 +2994,54 @@ mutation seedAll @transaction { vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" } ) + staff_attire_1: staffAttire_insert( + data: { + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + attireOptionId: "4bce6592-e38e-4d90-a478-d1ce0f286146" + verificationStatus: APPROVED + verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-7/non-slip-shoes.jpg" + verificationId: "attire_verif_staff7_001" + verifiedAt: "2026-03-01T17:00:00Z" + } + ) + staff_attire_2: staffAttire_insert( + data: { + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + attireOptionId: "786e9761-b398-42bd-b363-91a40938864e" + verificationStatus: APPROVED + verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-7/black-pants.jpg" + verificationId: "attire_verif_staff7_002" + verifiedAt: "2026-03-01T17:05:00Z" + } + ) + staff_attire_3: staffAttire_insert( + data: { + staffId: "e7f8a9b0-c1d2-4e5f-a6b7-c8d9e0f1a2b3" + attireOptionId: "bbff61b3-3f99-4637-9a2f-1d4c6fa61517" + verificationStatus: PROCESSING + verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-7/white-button-up.jpg" + verificationId: "attire_verif_staff7_003" + } + ) + staff_attire_4: staffAttire_insert( + data: { + staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57" + attireOptionId: "4bce6592-e38e-4d90-a478-d1ce0f286146" + verificationStatus: APPROVED + verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-1/non-slip-shoes.jpg" + verificationId: "attire_verif_staff1_001" + verifiedAt: "2026-02-21T16:20:00Z" + } + ) + staff_attire_5: staffAttire_insert( + data: { + staffId: "633df3ce-b92c-473f-90d8-38dd027fdf57" + attireOptionId: "17b135e6-b8f0-4541-b12b-505e95de31ef" + verificationStatus: APPROVED + verificationPhotoUrl: "https://storage.googleapis.com/krow-workforce-dev/attire/staff-1/black-socks.jpg" + verificationId: "attire_verif_staff1_002" + verifiedAt: "2026-02-21T16:23:00Z" + } + ) } -#v.3 +#v.4 diff --git a/backend/query-api/Dockerfile b/backend/query-api/Dockerfile new file mode 100644 index 00000000..55a6a26b --- /dev/null +++ b/backend/query-api/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY src ./src + +ENV PORT=8080 +EXPOSE 8080 + +CMD ["node", "src/server.js"] diff --git a/backend/query-api/package-lock.json b/backend/query-api/package-lock.json new file mode 100644 index 00000000..59e60898 --- /dev/null +++ b/backend/query-api/package-lock.json @@ -0,0 +1,3039 @@ +{ + "name": "@krow/query-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@krow/query-api", + "version": "0.1.0", + "dependencies": { + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "pg": "^8.20.0", + "pino": "^9.6.0", + "pino-http": "^10.3.0" + }, + "devDependencies": { + "supertest": "^7.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.0.tgz", + "integrity": "sha512-wR9En2A+WESUHexjmRHkqtaVH94WLNKt6rmeqZhSLBybg4Wyf0Umk04SZsS6sBq4102ZsDBFwoqMqJYj2IoDSg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.0.tgz", + "integrity": "sha512-gM6MJFae3pTyNLoc9VcJNuaUDej0ctdjn3cVtILo3D5lpp0dmUHHLFN/pUKe7ImyeB1KAvRlEYxvIHNF04Filg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.0", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.0.tgz", + "integrity": "sha512-8nYc43RqxScsePVd1qe1xxvWNf0OBnbwHxmXJ7MHSuuTVYFO3eLyLW3PiCKJ9fHnmIz4p4LbieXwz+qtr9PZDg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.0", + "@firebase/database": "1.1.0", + "@firebase/database-types": "1.0.16", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.13.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.16.tgz", + "integrity": "sha512-xkQLQfU5De7+SPhEGAXFBnDryUWhhlFXelEg2YeZOQMCdoe7dL64DDAd77SQsR+6uoXIZY5MB4y/inCs4GTfcw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.13.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.13.0.tgz", + "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.12", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.12.tgz", + "integrity": "sha512-0QEp0aPJYSyf6RrTjDB7HlKgNMTY+V2C7ESTaVt6G9gQ0rPLzTGz7OF2NXTLR5vcy7HJEtIUsyWLsfX0kTqJBA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT", + "optional": true + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT", + "optional": true + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", + "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/fast-xml-parser": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", + "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "fast-xml-builder": "^1.0.0", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/firebase-admin": { + "version": "13.6.1", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.6.1.tgz", + "integrity": "sha512-Zgc6yPtmPxAZo+FoK6LMG6zpSEsoSK8ifIR+IqF4oWuC3uWZU40OjxgfLTSFcsRlj/k/wD66zNv2UiTRreCNSw==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "@types/node": "^22.8.7", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^9.14.2", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.14.0" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", + "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz", + "integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/backend/query-api/package.json b/backend/query-api/package.json new file mode 100644 index 00000000..33e16830 --- /dev/null +++ b/backend/query-api/package.json @@ -0,0 +1,23 @@ +{ + "name": "@krow/query-api", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/server.js", + "test": "node --test" + }, + "dependencies": { + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "pg": "^8.20.0", + "pino": "^9.6.0", + "pino-http": "^10.3.0" + }, + "devDependencies": { + "supertest": "^7.0.0" + } +} diff --git a/backend/query-api/src/app.js b/backend/query-api/src/app.js new file mode 100644 index 00000000..43ff81da --- /dev/null +++ b/backend/query-api/src/app.js @@ -0,0 +1,32 @@ +import express from 'express'; +import pino from 'pino'; +import pinoHttp from 'pino-http'; +import { requestContext } from './middleware/request-context.js'; +import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; +import { healthRouter } from './routes/health.js'; +import { createQueryRouter } from './routes/query.js'; +import { createMobileQueryRouter } from './routes/mobile.js'; + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +export function createApp(options = {}) { + const app = express(); + + app.use(requestContext); + app.use( + pinoHttp({ + logger, + customProps: (req) => ({ requestId: req.requestId }), + }) + ); + app.use(express.json({ limit: '2mb' })); + + app.use(healthRouter); + app.use('/query', createQueryRouter(options.queryService)); + app.use('/query', createMobileQueryRouter(options.mobileQueryService)); + + app.use(notFoundHandler); + app.use(errorHandler); + + return app; +} diff --git a/backend/query-api/src/data/faqs.js b/backend/query-api/src/data/faqs.js new file mode 100644 index 00000000..f12a8906 --- /dev/null +++ b/backend/query-api/src/data/faqs.js @@ -0,0 +1,41 @@ +export const FAQ_CATEGORIES = [ + { + category: 'Getting Started', + items: [ + { + question: 'How do I complete my worker profile?', + answer: 'Finish your personal info, preferred locations, experience, emergency contact, attire, and tax forms so shift applications and clock-in become available.', + }, + { + question: 'Why can I not apply to shifts yet?', + answer: 'The worker profile must be complete before the platform allows applications and shift acceptance. Missing sections are returned by the profile completion endpoints.', + }, + ], + }, + { + category: 'Shifts And Attendance', + items: [ + { + question: 'How does clock-in work?', + answer: 'Clock-in validates that you are assigned to the shift, near the configured hub geofence, and using the expected clock-in source such as near-field communication when required.', + }, + { + question: 'What happens if I request a swap?', + answer: 'The assignment moves to swap-requested status so operations can refill the shift while keeping an audit trail of the original assignment.', + }, + ], + }, + { + category: 'Payments And Compliance', + items: [ + { + question: 'When do I see my earnings?', + answer: 'Completed and processed time records appear in the worker payments summary, history, and time-card screens after attendance closes and payment processing runs.', + }, + { + question: 'How are documents and certificates verified?', + answer: 'Uploads create verification jobs that run automatic checks first and then allow manual review when confidence is low or a provider is unavailable.', + }, + ], + }, +]; diff --git a/backend/query-api/src/lib/errors.js b/backend/query-api/src/lib/errors.js new file mode 100644 index 00000000..05548b32 --- /dev/null +++ b/backend/query-api/src/lib/errors.js @@ -0,0 +1,26 @@ +export class AppError extends Error { + constructor(code, message, status = 400, details = {}) { + super(message); + this.name = 'AppError'; + this.code = code; + this.status = status; + this.details = details; + } +} + +export function toErrorEnvelope(error, requestId) { + const status = error?.status && Number.isInteger(error.status) ? error.status : 500; + const code = error?.code || 'INTERNAL_ERROR'; + const message = error?.message || 'Unexpected error'; + const details = error?.details || {}; + + return { + status, + body: { + code, + message, + details, + requestId, + }, + }; +} diff --git a/backend/query-api/src/middleware/auth.js b/backend/query-api/src/middleware/auth.js new file mode 100644 index 00000000..9c62c86d --- /dev/null +++ b/backend/query-api/src/middleware/auth.js @@ -0,0 +1,45 @@ +import { AppError } from '../lib/errors.js'; +import { can } from '../services/policy.js'; +import { verifyFirebaseToken } from '../services/firebase-auth.js'; + +function getBearerToken(header) { + if (!header) return null; + const [scheme, token] = header.split(' '); + if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null; + return token; +} + +export async function requireAuth(req, _res, next) { + try { + const token = getBearerToken(req.get('Authorization')); + if (!token) { + throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401); + } + + if (process.env.AUTH_BYPASS === 'true') { + req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + return next(); + } + + const decoded = await verifyFirebaseToken(token); + req.actor = { + uid: decoded.uid, + email: decoded.email || null, + role: decoded.role || null, + }; + + return next(); + } catch (error) { + if (error instanceof AppError) return next(error); + return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401)); + } +} + +export function requirePolicy(action, resource) { + return (req, _res, next) => { + if (!can(action, resource, req.actor)) { + return next(new AppError('FORBIDDEN', 'Not allowed to perform this action', 403)); + } + return next(); + }; +} diff --git a/backend/query-api/src/middleware/error-handler.js b/backend/query-api/src/middleware/error-handler.js new file mode 100644 index 00000000..289395f3 --- /dev/null +++ b/backend/query-api/src/middleware/error-handler.js @@ -0,0 +1,25 @@ +import { toErrorEnvelope } from '../lib/errors.js'; + +export function notFoundHandler(req, res) { + res.status(404).json({ + code: 'NOT_FOUND', + message: `Route not found: ${req.method} ${req.path}`, + details: {}, + requestId: req.requestId, + }); +} + +export function errorHandler(error, req, res, _next) { + const envelope = toErrorEnvelope(error, req.requestId); + if (req.log) { + req.log.error( + { + errCode: envelope.body.code, + status: envelope.status, + details: envelope.body.details, + }, + envelope.body.message + ); + } + res.status(envelope.status).json(envelope.body); +} diff --git a/backend/query-api/src/middleware/request-context.js b/backend/query-api/src/middleware/request-context.js new file mode 100644 index 00000000..c633acbb --- /dev/null +++ b/backend/query-api/src/middleware/request-context.js @@ -0,0 +1,9 @@ +import { randomUUID } from 'node:crypto'; + +export function requestContext(req, res, next) { + const incoming = req.get('X-Request-Id'); + req.requestId = incoming || randomUUID(); + res.setHeader('X-Request-Id', req.requestId); + res.locals.startedAt = Date.now(); + next(); +} diff --git a/backend/query-api/src/routes/health.js b/backend/query-api/src/routes/health.js new file mode 100644 index 00000000..fed2a5fe --- /dev/null +++ b/backend/query-api/src/routes/health.js @@ -0,0 +1,45 @@ +import { Router } from 'express'; +import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js'; + +export const healthRouter = Router(); + +function healthHandler(req, res) { + res.status(200).json({ + ok: true, + service: 'krow-query-api', + version: process.env.SERVICE_VERSION || 'dev', + requestId: req.requestId, + }); +} + +healthRouter.get('/health', healthHandler); +healthRouter.get('/healthz', healthHandler); + +healthRouter.get('/readyz', async (req, res) => { + if (!isDatabaseConfigured()) { + return res.status(503).json({ + ok: false, + service: 'krow-query-api', + status: 'DATABASE_NOT_CONFIGURED', + requestId: req.requestId, + }); + } + + try { + const ok = await checkDatabaseHealth(); + return res.status(ok ? 200 : 503).json({ + ok, + service: 'krow-query-api', + status: ok ? 'READY' : 'DATABASE_UNAVAILABLE', + requestId: req.requestId, + }); + } catch (error) { + return res.status(503).json({ + ok: false, + service: 'krow-query-api', + status: 'DATABASE_UNAVAILABLE', + details: { message: error.message }, + requestId: req.requestId, + }); + } +}); diff --git a/backend/query-api/src/routes/mobile.js b/backend/query-api/src/routes/mobile.js new file mode 100644 index 00000000..402dc345 --- /dev/null +++ b/backend/query-api/src/routes/mobile.js @@ -0,0 +1,662 @@ +import { Router } from 'express'; +import { requireAuth, requirePolicy } from '../middleware/auth.js'; +import { + getCoverageReport, + getClientDashboard, + getClientSession, + getCoverageStats, + getCurrentAttendanceStatus, + getCurrentBill, + getDailyOpsReport, + getPaymentChart, + getPaymentsSummary, + getPersonalInfo, + getPerformanceReport, + getProfileSectionsStatus, + getPrivacySettings, + getForecastReport, + getNoShowReport, + getOrderReorderPreview, + listGeofenceIncidents, + getReportSummary, + getSavings, + getStaffDashboard, + getStaffProfileCompletion, + getStaffSession, + getStaffShiftDetail, + listAttireChecklist, + listAssignedShifts, + listBusinessAccounts, + listBusinessTeamMembers, + listCancelledShifts, + listCertificates, + listCostCenters, + listCoreTeam, + listCoverageByDate, + listCompletedShifts, + listEmergencyContacts, + listFaqCategories, + listHubManagers, + listHubs, + listIndustries, + listInvoiceHistory, + listOpenShifts, + listTaxForms, + listTimeCardEntries, + listOrderItemsByDateRange, + listPaymentsHistory, + listPendingAssignments, + listPendingInvoices, + listProfileDocuments, + listRecentReorders, + listSkills, + listStaffAvailability, + listStaffBankAccounts, + listStaffBenefits, + listTodayShifts, + listVendorRoles, + listVendors, + searchFaqs, + getSpendBreakdown, + getSpendReport, +} from '../services/mobile-query-service.js'; + +const defaultQueryService = { + getClientDashboard, + getClientSession, + getCoverageReport, + getCoverageStats, + getCurrentAttendanceStatus, + getCurrentBill, + getDailyOpsReport, + getPaymentChart, + getPaymentsSummary, + getPersonalInfo, + getPerformanceReport, + getProfileSectionsStatus, + getPrivacySettings, + getForecastReport, + getNoShowReport, + getOrderReorderPreview, + listGeofenceIncidents, + getReportSummary, + getSavings, + getSpendBreakdown, + getSpendReport, + getStaffDashboard, + getStaffProfileCompletion, + getStaffSession, + getStaffShiftDetail, + listAttireChecklist, + listAssignedShifts, + listBusinessAccounts, + listBusinessTeamMembers, + listCancelledShifts, + listCertificates, + listCostCenters, + listCoreTeam, + listCoverageByDate, + listCompletedShifts, + listEmergencyContacts, + listFaqCategories, + listHubManagers, + listHubs, + listIndustries, + listInvoiceHistory, + listOpenShifts, + listTaxForms, + listTimeCardEntries, + listOrderItemsByDateRange, + listPaymentsHistory, + listPendingAssignments, + listPendingInvoices, + listProfileDocuments, + listRecentReorders, + listSkills, + listStaffAvailability, + listStaffBankAccounts, + listStaffBenefits, + listTodayShifts, + listVendorRoles, + listVendors, + searchFaqs, +}; + +function requireQueryParam(name, value) { + if (!value) { + const error = new Error(`${name} is required`); + error.code = 'VALIDATION_ERROR'; + error.status = 400; + error.details = { field: name }; + throw error; + } + return value; +} + +export function createMobileQueryRouter(queryService = defaultQueryService) { + const router = Router(); + + router.get('/client/session', requireAuth, requirePolicy('client.session.read', 'session'), async (req, res, next) => { + try { + const data = await queryService.getClientSession(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/dashboard', requireAuth, requirePolicy('client.dashboard.read', 'dashboard'), async (req, res, next) => { + try { + const data = await queryService.getClientDashboard(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reorders', requireAuth, requirePolicy('orders.reorder.read', 'order'), async (req, res, next) => { + try { + const items = await queryService.listRecentReorders(req.actor.uid, req.query.limit); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/billing/accounts', requireAuth, requirePolicy('billing.accounts.read', 'billing'), async (req, res, next) => { + try { + const items = await queryService.listBusinessAccounts(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/billing/invoices/pending', requireAuth, requirePolicy('billing.invoices.read', 'billing'), async (req, res, next) => { + try { + const items = await queryService.listPendingInvoices(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/billing/invoices/history', requireAuth, requirePolicy('billing.invoices.read', 'billing'), async (req, res, next) => { + try { + const items = await queryService.listInvoiceHistory(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/billing/current-bill', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => { + try { + const data = await queryService.getCurrentBill(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/billing/savings', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => { + try { + const data = await queryService.getSavings(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/billing/spend-breakdown', requireAuth, requirePolicy('billing.summary.read', 'billing'), async (req, res, next) => { + try { + const items = await queryService.getSpendBreakdown(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/coverage', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const items = await queryService.listCoverageByDate(req.actor.uid, { date: requireQueryParam('date', req.query.date) }); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/coverage/stats', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const data = await queryService.getCoverageStats(req.actor.uid, { date: requireQueryParam('date', req.query.date) }); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/coverage/core-team', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const items = await queryService.listCoreTeam(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/coverage/incidents', requireAuth, requirePolicy('coverage.read', 'coverage'), async (req, res, next) => { + try { + const items = await queryService.listGeofenceIncidents(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/hubs', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { + try { + const items = await queryService.listHubs(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/cost-centers', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { + try { + const items = await queryService.listCostCenters(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/vendors', requireAuth, requirePolicy('vendors.read', 'vendor'), async (req, res, next) => { + try { + const items = await queryService.listVendors(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/vendors/:vendorId/roles', requireAuth, requirePolicy('vendors.read', 'vendor'), async (req, res, next) => { + try { + const items = await queryService.listVendorRoles(req.actor.uid, req.params.vendorId); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/hubs/:hubId/managers', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { + try { + const items = await queryService.listHubManagers(req.actor.uid, req.params.hubId); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/team-members', requireAuth, requirePolicy('hubs.read', 'hub'), async (req, res, next) => { + try { + const items = await queryService.listBusinessTeamMembers(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/orders/view', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => { + try { + const items = await queryService.listOrderItemsByDateRange(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/orders/:orderId/reorder-preview', requireAuth, requirePolicy('orders.read', 'order'), async (req, res, next) => { + try { + const data = await queryService.getOrderReorderPreview(req.actor.uid, req.params.orderId); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/summary', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getReportSummary(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/daily-ops', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getDailyOpsReport(req.actor.uid, { date: requireQueryParam('date', req.query.date) }); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/spend', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getSpendReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/coverage', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getCoverageReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/forecast', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getForecastReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/performance', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getPerformanceReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/client/reports/no-show', requireAuth, requirePolicy('reports.read', 'report'), async (req, res, next) => { + try { + const data = await queryService.getNoShowReport(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/session', requireAuth, requirePolicy('staff.session.read', 'session'), async (req, res, next) => { + try { + const data = await queryService.getStaffSession(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/dashboard', requireAuth, requirePolicy('staff.dashboard.read', 'dashboard'), async (req, res, next) => { + try { + const data = await queryService.getStaffDashboard(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile-completion', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const data = await queryService.getStaffProfileCompletion(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/availability', requireAuth, requirePolicy('staff.availability.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listStaffAvailability(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/clock-in/shifts/today', requireAuth, requirePolicy('attendance.read', 'attendance'), async (req, res, next) => { + try { + const items = await queryService.listTodayShifts(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/clock-in/status', requireAuth, requirePolicy('attendance.read', 'attendance'), async (req, res, next) => { + try { + const data = await queryService.getCurrentAttendanceStatus(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/payments/summary', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => { + try { + const data = await queryService.getPaymentsSummary(req.actor.uid, req.query); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/payments/history', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => { + try { + const items = await queryService.listPaymentsHistory(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/payments/chart', requireAuth, requirePolicy('payments.read', 'payment'), async (req, res, next) => { + try { + const items = await queryService.getPaymentChart(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/shifts/assigned', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { + try { + const items = await queryService.listAssignedShifts(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/shifts/open', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { + try { + const items = await queryService.listOpenShifts(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/shifts/pending', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { + try { + const items = await queryService.listPendingAssignments(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/shifts/cancelled', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { + try { + const items = await queryService.listCancelledShifts(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/shifts/completed', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { + try { + const items = await queryService.listCompletedShifts(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/shifts/:shiftId', requireAuth, requirePolicy('shifts.read', 'shift'), async (req, res, next) => { + try { + const data = await queryService.getStaffShiftDetail(req.actor.uid, req.params.shiftId); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/sections', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const data = await queryService.getProfileSectionsStatus(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/personal-info', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const data = await queryService.getPersonalInfo(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/industries', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listIndustries(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/skills', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listSkills(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/documents', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listProfileDocuments(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/attire', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listAttireChecklist(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/tax-forms', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listTaxForms(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/emergency-contacts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listEmergencyContacts(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/certificates', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listCertificates(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/bank-accounts', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listStaffBankAccounts(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/benefits', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listStaffBenefits(req.actor.uid); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/time-card', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const items = await queryService.listTimeCardEntries(req.actor.uid, req.query); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/profile/privacy', requireAuth, requirePolicy('staff.profile.read', 'staff'), async (req, res, next) => { + try { + const data = await queryService.getPrivacySettings(req.actor.uid); + return res.status(200).json({ ...data, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/faqs', async (req, res, next) => { + try { + const items = await queryService.listFaqCategories(); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + router.get('/staff/faqs/search', async (req, res, next) => { + try { + const items = await queryService.searchFaqs(req.query.q || ''); + return res.status(200).json({ items, requestId: req.requestId }); + } catch (error) { + return next(error); + } + }); + + return router; +} diff --git a/backend/query-api/src/routes/query.js b/backend/query-api/src/routes/query.js new file mode 100644 index 00000000..5fe33090 --- /dev/null +++ b/backend/query-api/src/routes/query.js @@ -0,0 +1,138 @@ +import { Router } from 'express'; +import { AppError } from '../lib/errors.js'; +import { requireAuth, requirePolicy } from '../middleware/auth.js'; +import { + getAssignmentAttendance, + getOrderDetail, + getStaffReviewSummary, + listFavoriteStaff, + listOrders, +} from '../services/query-service.js'; + +const defaultQueryService = { + getAssignmentAttendance, + getOrderDetail, + getStaffReviewSummary, + listFavoriteStaff, + listOrders, +}; + +function requireUuid(value, field) { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) { + throw new AppError('VALIDATION_ERROR', `${field} must be a UUID`, 400, { field }); + } + return value; +} + +export function createQueryRouter(queryService = defaultQueryService) { + const router = Router(); + + router.get( + '/tenants/:tenantId/orders', + requireAuth, + requirePolicy('orders.read', 'order'), + async (req, res, next) => { + try { + const tenantId = requireUuid(req.params.tenantId, 'tenantId'); + const orders = await queryService.listOrders({ + tenantId, + businessId: req.query.businessId, + status: req.query.status, + limit: req.query.limit, + offset: req.query.offset, + }); + return res.status(200).json({ + items: orders, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + } + ); + + router.get( + '/tenants/:tenantId/orders/:orderId', + requireAuth, + requirePolicy('orders.read', 'order'), + async (req, res, next) => { + try { + const order = await queryService.getOrderDetail({ + tenantId: requireUuid(req.params.tenantId, 'tenantId'), + orderId: requireUuid(req.params.orderId, 'orderId'), + }); + return res.status(200).json({ + ...order, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + } + ); + + router.get( + '/tenants/:tenantId/businesses/:businessId/favorite-staff', + requireAuth, + requirePolicy('business.favorite-staff.read', 'staff'), + async (req, res, next) => { + try { + const items = await queryService.listFavoriteStaff({ + tenantId: requireUuid(req.params.tenantId, 'tenantId'), + businessId: requireUuid(req.params.businessId, 'businessId'), + limit: req.query.limit, + offset: req.query.offset, + }); + return res.status(200).json({ + items, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + } + ); + + router.get( + '/tenants/:tenantId/staff/:staffId/review-summary', + requireAuth, + requirePolicy('staff.reviews.read', 'staff'), + async (req, res, next) => { + try { + const summary = await queryService.getStaffReviewSummary({ + tenantId: requireUuid(req.params.tenantId, 'tenantId'), + staffId: requireUuid(req.params.staffId, 'staffId'), + limit: req.query.limit, + }); + return res.status(200).json({ + ...summary, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + } + ); + + router.get( + '/tenants/:tenantId/assignments/:assignmentId/attendance', + requireAuth, + requirePolicy('attendance.read', 'attendance'), + async (req, res, next) => { + try { + const attendance = await queryService.getAssignmentAttendance({ + tenantId: requireUuid(req.params.tenantId, 'tenantId'), + assignmentId: requireUuid(req.params.assignmentId, 'assignmentId'), + }); + return res.status(200).json({ + ...attendance, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + } + ); + + return router; +} diff --git a/backend/query-api/src/server.js b/backend/query-api/src/server.js new file mode 100644 index 00000000..02002d9a --- /dev/null +++ b/backend/query-api/src/server.js @@ -0,0 +1,9 @@ +import { createApp } from './app.js'; + +const port = Number(process.env.PORT || 8080); +const app = createApp(); + +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`krow-query-api listening on port ${port}`); +}); diff --git a/backend/query-api/src/services/actor-context.js b/backend/query-api/src/services/actor-context.js new file mode 100644 index 00000000..30d23aa5 --- /dev/null +++ b/backend/query-api/src/services/actor-context.js @@ -0,0 +1,111 @@ +import { AppError } from '../lib/errors.js'; +import { query } from './db.js'; + +export async function loadActorContext(uid) { + const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([ + query( + ` + SELECT id AS "userId", email, display_name AS "displayName", phone, status + FROM users + WHERE id = $1 + `, + [uid] + ), + query( + ` + SELECT tm.id AS "membershipId", + tm.tenant_id AS "tenantId", + tm.base_role AS role, + t.name AS "tenantName", + t.slug AS "tenantSlug" + FROM tenant_memberships tm + JOIN tenants t ON t.id = tm.tenant_id + WHERE tm.user_id = $1 + AND tm.membership_status = 'ACTIVE' + ORDER BY tm.created_at ASC + LIMIT 1 + `, + [uid] + ), + query( + ` + SELECT bm.id AS "membershipId", + bm.business_id AS "businessId", + bm.business_role AS role, + b.business_name AS "businessName", + b.slug AS "businessSlug", + bm.tenant_id AS "tenantId" + FROM business_memberships bm + JOIN businesses b ON b.id = bm.business_id + WHERE bm.user_id = $1 + AND bm.membership_status = 'ACTIVE' + ORDER BY bm.created_at ASC + LIMIT 1 + `, + [uid] + ), + query( + ` + SELECT vm.id AS "membershipId", + vm.vendor_id AS "vendorId", + vm.vendor_role AS role, + v.company_name AS "vendorName", + v.slug AS "vendorSlug", + vm.tenant_id AS "tenantId" + FROM vendor_memberships vm + JOIN vendors v ON v.id = vm.vendor_id + WHERE vm.user_id = $1 + AND vm.membership_status = 'ACTIVE' + ORDER BY vm.created_at ASC + LIMIT 1 + `, + [uid] + ), + query( + ` + SELECT s.id AS "staffId", + s.tenant_id AS "tenantId", + s.full_name AS "fullName", + s.email, + s.phone, + s.primary_role AS "primaryRole", + s.onboarding_status AS "onboardingStatus", + s.status, + s.metadata, + w.id AS "workforceId", + w.vendor_id AS "vendorId", + w.workforce_number AS "workforceNumber" + FROM staffs s + LEFT JOIN workforce w ON w.staff_id = s.id + WHERE s.user_id = $1 + ORDER BY s.created_at ASC + LIMIT 1 + `, + [uid] + ), + ]); + + return { + user: userResult.rows[0] || null, + tenant: tenantResult.rows[0] || null, + business: businessResult.rows[0] || null, + vendor: vendorResult.rows[0] || null, + staff: staffResult.rows[0] || null, + }; +} + +export async function requireClientContext(uid) { + const context = await loadActorContext(uid); + if (!context.user || !context.tenant || !context.business) { + throw new AppError('FORBIDDEN', 'Client business context is required for this route', 403, { uid }); + } + return context; +} + +export async function requireStaffContext(uid) { + const context = await loadActorContext(uid); + if (!context.user || !context.tenant || !context.staff) { + throw new AppError('FORBIDDEN', 'Staff context is required for this route', 403, { uid }); + } + return context; +} diff --git a/backend/query-api/src/services/db.js b/backend/query-api/src/services/db.js new file mode 100644 index 00000000..a0af590b --- /dev/null +++ b/backend/query-api/src/services/db.js @@ -0,0 +1,84 @@ +import pg from 'pg'; + +const { Pool, types } = pg; + +function parseNumericDatabaseValue(value) { + if (value == null) return value; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; +} + +// Mobile/frontend routes expect numeric JSON values for database aggregates. +types.setTypeParser(types.builtins.INT8, parseNumericDatabaseValue); +types.setTypeParser(types.builtins.NUMERIC, parseNumericDatabaseValue); + +let pool; + +function parseIntOrDefault(value, fallback) { + const parsed = Number.parseInt(`${value || fallback}`, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function resolveDatabasePoolConfig() { + if (process.env.DATABASE_URL) { + return { + connectionString: process.env.DATABASE_URL, + max: parseIntOrDefault(process.env.DB_POOL_MAX, 10), + idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000), + }; + } + + const user = process.env.DB_USER; + const password = process.env.DB_PASSWORD; + const database = process.env.DB_NAME; + const host = process.env.DB_HOST || ( + process.env.INSTANCE_CONNECTION_NAME + ? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}` + : '' + ); + + if (!user || password == null || !database || !host) { + return null; + } + + return { + host, + port: parseIntOrDefault(process.env.DB_PORT, 5432), + user, + password, + database, + max: parseIntOrDefault(process.env.DB_POOL_MAX, 10), + idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000), + }; +} + +export function isDatabaseConfigured() { + return Boolean(resolveDatabasePoolConfig()); +} + +function getPool() { + if (!pool) { + const resolved = resolveDatabasePoolConfig(); + if (!resolved) { + throw new Error('Database connection settings are required'); + } + pool = new Pool(resolved); + } + return pool; +} + +export async function query(text, params = []) { + return getPool().query(text, params); +} + +export async function checkDatabaseHealth() { + const result = await query('SELECT 1 AS ok'); + return result.rows[0]?.ok === 1; +} + +export async function closePool() { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/backend/query-api/src/services/firebase-auth.js b/backend/query-api/src/services/firebase-auth.js new file mode 100644 index 00000000..e268d5db --- /dev/null +++ b/backend/query-api/src/services/firebase-auth.js @@ -0,0 +1,13 @@ +import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; +import { getAuth } from 'firebase-admin/auth'; + +function ensureAdminApp() { + if (getApps().length === 0) { + initializeApp({ credential: applicationDefault() }); + } +} + +export async function verifyFirebaseToken(token) { + ensureAdminApp(); + return getAuth().verifyIdToken(token); +} diff --git a/backend/query-api/src/services/mobile-query-service.js b/backend/query-api/src/services/mobile-query-service.js new file mode 100644 index 00000000..4cbb7e53 --- /dev/null +++ b/backend/query-api/src/services/mobile-query-service.js @@ -0,0 +1,1746 @@ +import { AppError } from '../lib/errors.js'; +import { FAQ_CATEGORIES } from '../data/faqs.js'; +import { query } from './db.js'; +import { requireClientContext, requireStaffContext } from './actor-context.js'; + +function parseLimit(value, fallback = 20, max = 100) { + const parsed = Number.parseInt(`${value || fallback}`, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, max); +} + +function parseDate(value, field) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new AppError('VALIDATION_ERROR', `${field} must be a valid ISO date`, 400, { field }); + } + return date; +} + +function parseDateRange(startDate, endDate, fallbackDays = 7) { + const start = startDate ? parseDate(startDate, 'startDate') : new Date(); + const end = endDate ? parseDate(endDate, 'endDate') : new Date(start.getTime() + (fallbackDays * 24 * 60 * 60 * 1000)); + if (start > end) { + throw new AppError('VALIDATION_ERROR', 'startDate must be before endDate', 400); + } + return { + start: start.toISOString(), + end: end.toISOString(), + }; +} + +function startOfDay(value) { + const date = parseDate(value || new Date().toISOString(), 'date'); + date.setUTCHours(0, 0, 0, 0); + return date; +} + +function endOfDay(value) { + const date = startOfDay(value); + date.setUTCDate(date.getUTCDate() + 1); + return date; +} + +function metadataArray(metadata, key) { + const value = metadata?.[key]; + return Array.isArray(value) ? value : []; +} + +function metadataBoolean(metadata, key, fallback = false) { + const value = metadata?.[key]; + if (typeof value === 'boolean') return value; + return fallback; +} + +function getProfileCompletionFromMetadata(staffRow) { + const metadata = staffRow?.metadata || {}; + const [firstName, ...lastParts] = (staffRow?.fullName || '').trim().split(/\s+/); + const lastName = lastParts.join(' '); + + const checks = { + firstName: Boolean(metadata.firstName || firstName), + lastName: Boolean(metadata.lastName || lastName), + email: Boolean(staffRow?.email), + phone: Boolean(staffRow?.phone), + preferredLocations: metadataArray(metadata, 'preferredLocations').length > 0, + skills: metadataArray(metadata, 'skills').length > 0, + industries: metadataArray(metadata, 'industries').length > 0, + emergencyContact: Boolean(metadata.emergencyContact?.name && metadata.emergencyContact?.phone), + }; + + const missingFields = Object.entries(checks) + .filter(([, value]) => !value) + .map(([key]) => key); + + return { + completed: missingFields.length === 0, + missingFields, + fields: checks, + }; +} + +export async function getClientSession(actorUid) { + const context = await requireClientContext(actorUid); + return context; +} + +export async function getStaffSession(actorUid) { + const context = await requireStaffContext(actorUid); + return context; +} + +export async function getClientDashboard(actorUid) { + const context = await requireClientContext(actorUid); + const businessId = context.business.businessId; + const tenantId = context.tenant.tenantId; + + const [spendResult, projectionResult, coverageResult, activityResult] = await Promise.all([ + query( + ` + SELECT + COALESCE(SUM(total_cents) FILTER (WHERE created_at >= date_trunc('week', NOW())), 0)::BIGINT AS "weeklySpendCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + `, + [tenantId, businessId] + ), + query( + ` + SELECT COALESCE(SUM(sr.bill_rate_cents * sr.workers_needed), 0)::BIGINT AS "projectedSpendCents" + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= NOW() + AND s.starts_at < NOW() + INTERVAL '7 days' + AND s.status IN ('OPEN', 'PENDING_CONFIRMATION', 'ASSIGNED', 'ACTIVE') + `, + [tenantId, businessId] + ), + query( + ` + SELECT + COALESCE(SUM(required_workers), 0)::INTEGER AS "neededWorkersToday", + COALESCE(SUM(assigned_workers), 0)::INTEGER AS "filledWorkersToday", + COALESCE(SUM(required_workers - assigned_workers), 0)::INTEGER AS "openPositionsToday" + FROM shifts + WHERE tenant_id = $1 + AND business_id = $2 + AND starts_at >= date_trunc('day', NOW()) + AND starts_at < date_trunc('day', NOW()) + INTERVAL '1 day' + `, + [tenantId, businessId] + ), + query( + ` + SELECT + COALESCE(COUNT(*) FILTER (WHERE a.status = 'NO_SHOW'), 0)::INTEGER AS "lateWorkersToday", + COALESCE(COUNT(*) FILTER (WHERE a.status IN ('CHECKED_IN', 'CHECKED_OUT', 'COMPLETED')), 0)::INTEGER AS "checkedInWorkersToday", + COALESCE(AVG(sr.bill_rate_cents), 0)::NUMERIC(12,2) AS "averageShiftCostCents" + FROM shifts s + LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= date_trunc('day', NOW()) + AND s.starts_at < date_trunc('day', NOW()) + INTERVAL '1 day' + `, + [tenantId, businessId] + ), + ]); + + return { + userName: context.user.displayName || context.user.email, + businessName: context.business.businessName, + businessId, + spending: { + weeklySpendCents: Number(spendResult.rows[0]?.weeklySpendCents || 0), + projectedNext7DaysCents: Number(projectionResult.rows[0]?.projectedSpendCents || 0), + }, + coverage: coverageResult.rows[0], + liveActivity: activityResult.rows[0], + }; +} + +export async function listRecentReorders(actorUid, limit) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + o.id, + o.title, + o.starts_at AS "date", + COALESCE(cp.label, o.location_name) AS "hubName", + COALESCE(COUNT(sr.id), 0)::INTEGER AS "positionCount", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType" + FROM orders o + LEFT JOIN shifts s ON s.order_id = o.id + LEFT JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE o.tenant_id = $1 + AND o.business_id = $2 + AND o.status IN ('COMPLETED', 'ACTIVE', 'FILLED') + GROUP BY o.id, cp.label + ORDER BY o.starts_at DESC NULLS LAST + LIMIT $3 + `, + [context.tenant.tenantId, context.business.businessId, parseLimit(limit, 8, 20)] + ); + return result.rows; +} + +export async function listBusinessAccounts(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + id AS "accountId", + provider_name AS "bankName", + provider_reference AS "providerReference", + last4, + is_primary AS "isPrimary", + COALESCE(metadata->>'accountType', 'CHECKING') AS "accountType", + COALESCE(metadata->>'routingNumberMasked', '***') AS "routingNumberMasked" + FROM accounts + WHERE tenant_id = $1 + AND owner_business_id = $2 + ORDER BY is_primary DESC, created_at DESC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function listPendingInvoices(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + i.id AS "invoiceId", + i.invoice_number AS "invoiceNumber", + i.total_cents AS "amountCents", + i.status, + i.due_at AS "dueDate", + v.id AS "vendorId", + v.company_name AS "vendorName" + FROM invoices i + LEFT JOIN vendors v ON v.id = i.vendor_id + WHERE i.tenant_id = $1 + AND i.business_id = $2 + AND i.status IN ('PENDING', 'PENDING_REVIEW', 'APPROVED', 'OVERDUE', 'DISPUTED') + ORDER BY i.due_at ASC NULLS LAST, i.created_at DESC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function listInvoiceHistory(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + i.id AS "invoiceId", + i.invoice_number AS "invoiceNumber", + i.total_cents AS "amountCents", + i.status, + i.updated_at AS "paymentDate", + v.id AS "vendorId", + v.company_name AS "vendorName" + FROM invoices i + LEFT JOIN vendors v ON v.id = i.vendor_id + WHERE i.tenant_id = $1 + AND i.business_id = $2 + ORDER BY i.created_at DESC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function getCurrentBill(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "currentBillCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND status NOT IN ('PAID', 'VOID') + AND created_at >= date_trunc('month', NOW()) + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows[0]; +} + +export async function getSavings(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT COALESCE(SUM(COALESCE(NULLIF(metadata->>'savingsCents', '')::BIGINT, 0)), 0)::BIGINT AS "savingsCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows[0]; +} + +export async function getSpendBreakdown(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const result = await query( + ` + WITH items AS ( + SELECT + COALESCE(sr.role_name, 'Unknown') AS category, + SUM(sr.bill_rate_cents * GREATEST(sr.assigned_count, sr.workers_needed))::BIGINT AS amount_cents + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + GROUP BY sr.role_name + ) + SELECT + category, + amount_cents AS "amountCents", + CASE WHEN SUM(amount_cents) OVER () = 0 THEN 0 + ELSE ROUND((amount_cents::numeric / SUM(amount_cents) OVER ()) * 100, 2) + END AS percentage + FROM items + ORDER BY amount_cents DESC, category ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + return result.rows; +} + +export async function listCoverageByDate(actorUid, { date }) { + const context = await requireClientContext(actorUid); + const from = startOfDay(date).toISOString(); + const to = endOfDay(date).toISOString(); + const result = await query( + ` + SELECT + s.id AS "shiftId", + s.title, + s.starts_at AS "startsAt", + s.ends_at AS "endsAt", + s.required_workers AS "requiredWorkers", + s.assigned_workers AS "assignedWorkers", + sr.role_name AS "roleName", + a.id AS "assignmentId", + a.status AS "assignmentStatus", + st.id AS "staffId", + st.full_name AS "staffName", + attendance_sessions.check_in_at AS "checkInAt" + FROM shifts s + LEFT JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN staffs st ON st.id = a.staff_id + LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at < $4::timestamptz + ORDER BY s.starts_at ASC, st.full_name ASC NULLS LAST + `, + [context.tenant.tenantId, context.business.businessId, from, to] + ); + + const grouped = new Map(); + for (const row of result.rows) { + const current = grouped.get(row.shiftId) || { + shiftId: row.shiftId, + roleName: row.roleName, + timeRange: { + startsAt: row.startsAt, + endsAt: row.endsAt, + }, + requiredWorkerCount: row.requiredWorkers, + assignedWorkerCount: row.assignedWorkers, + assignedWorkers: [], + }; + if (row.staffId) { + current.assignedWorkers.push({ + assignmentId: row.assignmentId, + staffId: row.staffId, + fullName: row.staffName, + status: row.assignmentStatus, + checkInAt: row.checkInAt, + }); + } + grouped.set(row.shiftId, current); + } + + return Array.from(grouped.values()); +} + +export async function getCoverageStats(actorUid, { date }) { + const items = await listCoverageByDate(actorUid, { date }); + const totals = items.reduce((acc, item) => { + acc.totalPositionsNeeded += Number(item.requiredWorkerCount || 0); + acc.totalPositionsConfirmed += Number(item.assignedWorkerCount || 0); + acc.totalWorkersCheckedIn += item.assignedWorkers.filter((worker) => worker.checkInAt).length; + acc.totalWorkersEnRoute += item.assignedWorkers.filter((worker) => worker.status === 'ACCEPTED').length; + acc.totalWorkersLate += item.assignedWorkers.filter((worker) => worker.status === 'NO_SHOW').length; + return acc; + }, { + totalPositionsNeeded: 0, + totalPositionsConfirmed: 0, + totalWorkersCheckedIn: 0, + totalWorkersEnRoute: 0, + totalWorkersLate: 0, + }); + + return { + ...totals, + totalCoveragePercentage: totals.totalPositionsNeeded === 0 + ? 0 + : Math.round((totals.totalPositionsConfirmed / totals.totalPositionsNeeded) * 100), + }; +} + +export async function listHubs(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + cp.id AS "hubId", + cp.label AS name, + cp.address AS "fullAddress", + cp.latitude, + cp.longitude, + cp.geofence_radius_meters AS "geofenceRadiusMeters", + cp.nfc_tag_uid AS "nfcTagId", + cp.default_clock_in_mode AS "clockInMode", + cp.allow_clock_in_override AS "allowClockInOverride", + cp.metadata->>'city' AS city, + cp.metadata->>'state' AS state, + cp.metadata->>'zipCode' AS "zipCode", + cc.id AS "costCenterId", + cc.name AS "costCenterName" + FROM clock_points cp + LEFT JOIN cost_centers cc ON cc.id = cp.cost_center_id + WHERE cp.tenant_id = $1 + AND cp.business_id = $2 + AND cp.status = 'ACTIVE' + ORDER BY cp.label ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function listCostCenters(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT id AS "costCenterId", name + FROM cost_centers + WHERE tenant_id = $1 + AND business_id = $2 + AND status = 'ACTIVE' + ORDER BY name ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function listVendors(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT id AS "vendorId", company_name AS "vendorName" + FROM vendors + WHERE tenant_id = $1 + AND status = 'ACTIVE' + ORDER BY company_name ASC + `, + [context.tenant.tenantId] + ); + return result.rows; +} + +export async function listVendorRoles(actorUid, vendorId) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + rc.id AS "roleId", + rc.code AS "roleCode", + rc.name AS "roleName", + COALESCE(MAX(sr.bill_rate_cents), 0)::INTEGER AS "hourlyRateCents" + FROM roles_catalog rc + LEFT JOIN shift_roles sr ON sr.role_id = rc.id + LEFT JOIN shifts s ON s.id = sr.shift_id AND (s.vendor_id = $2 OR $2::uuid IS NULL) + WHERE rc.tenant_id = $1 + AND rc.status = 'ACTIVE' + GROUP BY rc.id + ORDER BY rc.name ASC + `, + [context.tenant.tenantId, vendorId || null] + ); + return result.rows; +} + +export async function listHubManagers(actorUid, hubId) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + hm.id AS "managerAssignmentId", + bm.id AS "businessMembershipId", + u.id AS "managerId", + COALESCE(u.display_name, u.email) AS name + FROM hub_managers hm + JOIN business_memberships bm ON bm.id = hm.business_membership_id + JOIN users u ON u.id = bm.user_id + WHERE hm.tenant_id = $1 + AND hm.hub_id = $2 + ORDER BY name ASC + `, + [context.tenant.tenantId, hubId] + ); + return result.rows; +} + +export async function listOrderItemsByDateRange(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 14); + const result = await query( + ` + SELECT + sr.id AS "itemId", + o.id AS "orderId", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + sr.role_name AS "roleName", + s.starts_at AS date, + s.starts_at AS "startsAt", + s.ends_at AS "endsAt", + sr.workers_needed AS "requiredWorkerCount", + sr.assigned_count AS "filledCount", + sr.bill_rate_cents AS "hourlyRateCents", + (sr.bill_rate_cents * sr.workers_needed)::BIGINT AS "totalCostCents", + COALESCE(cp.label, s.location_name) AS "locationName", + s.status, + COALESCE( + json_agg( + json_build_object( + 'applicationId', a.application_id, + 'workerName', st.full_name, + 'role', sr.role_name, + 'confirmationStatus', a.status + ) + ) FILTER (WHERE a.id IS NOT NULL), + '[]'::json + ) AS workers + FROM shift_roles sr + JOIN shifts s ON s.id = sr.shift_id + JOIN orders o ON o.id = s.order_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN assignments a ON a.shift_role_id = sr.id + LEFT JOIN staffs st ON st.id = a.staff_id + WHERE o.tenant_id = $1 + AND o.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + GROUP BY sr.id, o.id, s.id, cp.label + ORDER BY s.starts_at ASC, sr.role_name ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + return result.rows; +} + +export async function getStaffDashboard(actorUid) { + const context = await requireStaffContext(actorUid); + const [todayShifts, tomorrowShifts, recommendedShifts, benefits] = await Promise.all([ + listTodayShifts(actorUid), + listAssignedShifts(actorUid, { + startDate: endOfDay(new Date().toISOString()).toISOString(), + endDate: endOfDay(new Date(Date.now() + (24 * 60 * 60 * 1000)).toISOString()).toISOString(), + }), + listOpenShifts(actorUid, { limit: 5 }), + listStaffBenefits(actorUid), + ]); + + return { + staffName: context.staff.fullName, + todaysShifts: todayShifts, + tomorrowsShifts: tomorrowShifts.slice(0, 5), + recommendedShifts: recommendedShifts.slice(0, 5), + benefits, + }; +} + +export async function getStaffProfileCompletion(actorUid) { + const context = await requireStaffContext(actorUid); + const completion = getProfileCompletionFromMetadata(context.staff); + return { + staffId: context.staff.staffId, + ...completion, + }; +} + +export async function listStaffAvailability(actorUid, { startDate, endDate }) { + const context = await requireStaffContext(actorUid); + const range = parseDateRange(startDate, endDate, 6); + const recurring = await query( + ` + SELECT day_of_week AS "dayOfWeek", availability_status AS status, time_slots AS slots + FROM staff_availability + WHERE tenant_id = $1 + AND staff_id = $2 + ORDER BY day_of_week ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + + const rowsByDay = new Map(recurring.rows.map((row) => [Number(row.dayOfWeek), row])); + const items = []; + let cursor = new Date(range.start); + const end = new Date(range.end); + while (cursor <= end) { + const day = cursor.getUTCDay(); + const recurringEntry = rowsByDay.get(day); + items.push({ + date: cursor.toISOString().slice(0, 10), + dayOfWeek: day, + availabilityStatus: recurringEntry?.status || 'UNAVAILABLE', + slots: recurringEntry?.slots || [], + }); + cursor = new Date(cursor.getTime() + (24 * 60 * 60 * 1000)); + } + return items; +} + +export async function listTodayShifts(actorUid) { + const context = await requireStaffContext(actorUid); + const from = startOfDay(new Date().toISOString()).toISOString(); + const to = endOfDay(new Date().toISOString()).toISOString(); + const result = await query( + ` + SELECT + a.id AS "assignmentId", + s.id AS "shiftId", + sr.role_name AS "roleName", + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + COALESCE(s.clock_in_mode, cp.default_clock_in_mode, 'EITHER') AS "clockInMode", + COALESCE(s.allow_clock_in_override, cp.allow_clock_in_override, TRUE) AS "allowClockInOverride", + COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS "geofenceRadiusMeters", + cp.nfc_tag_uid AS "nfcTagId", + COALESCE(attendance_sessions.status, 'NOT_CLOCKED_IN') AS "attendanceStatus", + attendance_sessions.check_in_at AS "clockInAt" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at < $4::timestamptz + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + ORDER BY ABS(EXTRACT(EPOCH FROM (s.starts_at - NOW()))) ASC + `, + [context.tenant.tenantId, context.staff.staffId, from, to] + ); + return result.rows; +} + +export async function getCurrentAttendanceStatus(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + a.shift_id AS "activeShiftId", + attendance_sessions.status AS "attendanceStatus", + attendance_sessions.check_in_at AS "clockInAt" + FROM attendance_sessions + JOIN assignments a ON a.id = attendance_sessions.assignment_id + WHERE attendance_sessions.tenant_id = $1 + AND attendance_sessions.staff_id = $2 + AND attendance_sessions.status = 'OPEN' + ORDER BY attendance_sessions.updated_at DESC + LIMIT 1 + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows[0] || { + attendanceStatus: 'NOT_CLOCKED_IN', + activeShiftId: null, + clockInAt: null, + }; +} + +export async function getPaymentsSummary(actorUid, { startDate, endDate }) { + const context = await requireStaffContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const result = await query( + ` + SELECT COALESCE(SUM(amount_cents), 0)::BIGINT AS "totalEarningsCents" + FROM recent_payments + WHERE tenant_id = $1 + AND staff_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.staff.staffId, range.start, range.end] + ); + return result.rows[0]; +} + +export async function listPaymentsHistory(actorUid, { startDate, endDate }) { + const context = await requireStaffContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const result = await query( + ` + SELECT + rp.id AS "paymentId", + rp.amount_cents AS "amountCents", + COALESCE(rp.process_date, rp.created_at) AS date, + rp.status, + s.title AS "shiftName", + COALESCE(cp.label, s.location_name) AS location, + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS minutesWorked + FROM recent_payments rp + LEFT JOIN assignments a ON a.id = rp.assignment_id + LEFT JOIN shifts s ON s.id = a.shift_id + LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id + LEFT JOIN timesheets ts ON ts.assignment_id = a.id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE rp.tenant_id = $1 + AND rp.staff_id = $2 + AND rp.created_at >= $3::timestamptz + AND rp.created_at <= $4::timestamptz + ORDER BY date DESC + `, + [context.tenant.tenantId, context.staff.staffId, range.start, range.end] + ); + return result.rows; +} + +export async function getPaymentChart(actorUid, { startDate, endDate, bucket = 'day' }) { + const context = await requireStaffContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const dateBucket = bucket === 'week' ? 'week' : bucket === 'month' ? 'month' : 'day'; + const result = await query( + ` + SELECT + date_trunc('${dateBucket}', COALESCE(process_date, created_at)) AS bucket, + COALESCE(SUM(amount_cents), 0)::BIGINT AS "amountCents" + FROM recent_payments + WHERE tenant_id = $1 + AND staff_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + GROUP BY 1 + ORDER BY 1 ASC + `, + [context.tenant.tenantId, context.staff.staffId, range.start, range.end] + ); + return result.rows; +} + +export async function listAssignedShifts(actorUid, { startDate, endDate }) { + const context = await requireStaffContext(actorUid); + const range = parseDateRange(startDate, endDate, 14); + const result = await query( + ` + SELECT + a.id AS "assignmentId", + s.id AS "shiftId", + sr.role_name AS "roleName", + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + a.status + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + JOIN orders o ON o.id = s.order_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + AND a.status IN ('ASSIGNED', 'ACCEPTED', 'CHECKED_IN', 'CHECKED_OUT', 'COMPLETED') + ORDER BY s.starts_at ASC + `, + [context.tenant.tenantId, context.staff.staffId, range.start, range.end] + ); + return result.rows; +} + +export async function listOpenShifts(actorUid, { limit, search } = {}) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + WITH open_roles AS ( + SELECT + s.id AS "shiftId", + sr.id AS "roleId", + sr.role_name AS "roleName", + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + FALSE AS "instantBook", + sr.workers_needed AS "requiredWorkerCount" + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + JOIN orders o ON o.id = s.order_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE s.tenant_id = $1 + AND s.status = 'OPEN' + AND sr.role_code = $4 + AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') + AND NOT EXISTS ( + SELECT 1 + FROM applications a + WHERE a.shift_role_id = sr.id + AND a.staff_id = $3 + AND a.status IN ('PENDING', 'CONFIRMED') + ) + ), + swap_roles AS ( + SELECT + s.id AS "shiftId", + sr.id AS "roleId", + sr.role_name AS "roleName", + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + FALSE AS "instantBook", + 1::INTEGER AS "requiredWorkerCount" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + JOIN orders o ON o.id = s.order_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.status = 'SWAP_REQUESTED' + AND a.staff_id <> $3 + AND sr.role_code = $4 + AND ($2::text IS NULL OR sr.role_name ILIKE '%' || $2 || '%' OR COALESCE(cp.label, s.location_name) ILIKE '%' || $2 || '%') + AND NOT EXISTS ( + SELECT 1 + FROM applications app + WHERE app.shift_role_id = sr.id + AND app.staff_id = $3 + AND app.status IN ('PENDING', 'CONFIRMED') + ) + ) + SELECT * + FROM ( + SELECT * FROM open_roles + UNION ALL + SELECT * FROM swap_roles + ) items + ORDER BY "startTime" ASC + LIMIT $5 + `, + [ + context.tenant.tenantId, + search || null, + context.staff.staffId, + context.staff.primaryRole || 'BARISTA', + parseLimit(limit, 20, 100), + ] + ); + return result.rows; +} + +export async function listPendingAssignments(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + a.id AS "assignmentId", + s.id AS "shiftId", + s.title, + sr.role_name AS "roleName", + s.starts_at AS "startTime", + s.ends_at AS "endTime", + COALESCE(cp.label, s.location_name) AS location, + a.created_at AS "responseDeadline" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND a.status = 'ASSIGNED' + ORDER BY s.starts_at ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function getStaffShiftDetail(actorUid, shiftId) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + s.id AS "shiftId", + s.title, + o.description, + COALESCE(cp.label, s.location_name) AS location, + s.location_address AS address, + s.starts_at AS date, + s.starts_at AS "startTime", + s.ends_at AS "endTime", + COALESCE(s.clock_in_mode, cp.default_clock_in_mode, 'EITHER') AS "clockInMode", + COALESCE(s.allow_clock_in_override, cp.allow_clock_in_override, TRUE) AS "allowClockInOverride", + COALESCE(s.geofence_radius_meters, cp.geofence_radius_meters) AS "geofenceRadiusMeters", + cp.nfc_tag_uid AS "nfcTagId", + sr.id AS "roleId", + sr.role_name AS "roleName", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(o.metadata->>'orderType', 'ONE_TIME') AS "orderType", + sr.workers_needed AS "requiredCount", + sr.assigned_count AS "confirmedCount", + a.status AS "assignmentStatus", + app.status AS "applicationStatus" + FROM shifts s + JOIN orders o ON o.id = s.order_id + JOIN shift_roles sr ON sr.shift_id = s.id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN assignments a ON a.shift_role_id = sr.id AND a.staff_id = $3 + LEFT JOIN applications app ON app.shift_role_id = sr.id AND app.staff_id = $3 + WHERE s.tenant_id = $1 + AND s.id = $2 + ORDER BY sr.role_name ASC + LIMIT 1 + `, + [context.tenant.tenantId, shiftId, context.staff.staffId] + ); + + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Shift not found', 404, { shiftId }); + } + + return result.rows[0]; +} + +export async function listCancelledShifts(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + a.id AS "assignmentId", + s.id AS "shiftId", + s.title, + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + a.metadata->>'cancellationReason' AS "cancellationReason" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND a.status = 'CANCELLED' + ORDER BY s.starts_at DESC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listCompletedShifts(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + a.id AS "assignmentId", + s.id AS "shiftId", + s.title, + COALESCE(cp.label, s.location_name) AS location, + s.starts_at AS date, + COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked", + COALESCE(rp.status, 'PENDING') AS "paymentStatus" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + LEFT JOIN timesheets ts ON ts.assignment_id = a.id + LEFT JOIN recent_payments rp ON rp.assignment_id = a.id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND a.status IN ('CHECKED_OUT', 'COMPLETED') + ORDER BY s.starts_at DESC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function getProfileSectionsStatus(actorUid) { + const context = await requireStaffContext(actorUid); + const completion = getProfileCompletionFromMetadata(context.staff); + const [documents, certificates, benefits] = await Promise.all([ + listProfileDocuments(actorUid), + listCertificates(actorUid), + listStaffBenefits(actorUid), + ]); + return { + personalInfoCompleted: completion.fields.firstName && completion.fields.lastName && completion.fields.email && completion.fields.phone && completion.fields.preferredLocations, + emergencyContactCompleted: completion.fields.emergencyContact, + experienceCompleted: completion.fields.skills && completion.fields.industries, + attireCompleted: documents.filter((item) => item.documentType === 'ATTIRE').every((item) => item.status === 'VERIFIED'), + taxFormsCompleted: documents.filter((item) => item.documentType === 'TAX_FORM').every((item) => item.status === 'VERIFIED'), + benefitsConfigured: benefits.length > 0, + certificateCount: certificates.length, + }; +} + +export async function getPersonalInfo(actorUid) { + const context = await requireStaffContext(actorUid); + const metadata = context.staff.metadata || {}; + return { + staffId: context.staff.staffId, + firstName: metadata.firstName || context.staff.fullName.split(' ')[0] || null, + lastName: metadata.lastName || context.staff.fullName.split(' ').slice(1).join(' ') || null, + bio: metadata.bio || null, + preferredLocations: metadataArray(metadata, 'preferredLocations'), + maxDistanceMiles: metadata.maxDistanceMiles || null, + industries: metadataArray(metadata, 'industries'), + skills: metadataArray(metadata, 'skills'), + email: context.staff.email, + phone: context.staff.phone, + }; +} + +export async function listIndustries(actorUid) { + const context = await requireStaffContext(actorUid); + return metadataArray(context.staff.metadata || {}, 'industries'); +} + +export async function listSkills(actorUid) { + const context = await requireStaffContext(actorUid); + return metadataArray(context.staff.metadata || {}, 'skills'); +} + +export async function listProfileDocuments(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + d.id AS "documentId", + d.document_type AS "documentType", + d.name, + sd.id AS "staffDocumentId", + sd.file_uri AS "fileUri", + COALESCE(sd.status, 'NOT_UPLOADED') AS status, + sd.expires_at AS "expiresAt", + sd.metadata + FROM documents d + LEFT JOIN staff_documents sd + ON sd.document_id = d.id + AND sd.tenant_id = d.tenant_id + AND sd.staff_id = $2 + WHERE d.tenant_id = $1 + AND d.document_type IN ('DOCUMENT', 'GOVERNMENT_ID', 'ATTIRE', 'TAX_FORM') + ORDER BY d.name ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listCertificates(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + id AS "certificateId", + certificate_type AS "certificateType", + COALESCE(metadata->>'name', certificate_type) AS name, + file_uri AS "fileUri", + metadata->>'issuer' AS issuer, + certificate_number AS "certificateNumber", + issued_at AS "issuedAt", + expires_at AS "expiresAt", + status, + metadata->>'verificationStatus' AS "verificationStatus" + FROM certificates + WHERE tenant_id = $1 + AND staff_id = $2 + ORDER BY created_at DESC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listStaffBankAccounts(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + id AS "accountId", + provider_name AS "bankName", + provider_reference AS "providerReference", + last4, + is_primary AS "isPrimary", + COALESCE(metadata->>'accountType', 'CHECKING') AS "accountType" + FROM accounts + WHERE tenant_id = $1 + AND owner_staff_id = $2 + ORDER BY is_primary DESC, created_at DESC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listStaffBenefits(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + id AS "benefitId", + benefit_type AS "benefitType", + title, + status, + tracked_hours AS "trackedHours", + target_hours AS "targetHours", + metadata + FROM staff_benefits + WHERE tenant_id = $1 + AND staff_id = $2 + AND status = 'ACTIVE' + ORDER BY created_at ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listCoreTeam(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + st.id AS "staffId", + st.full_name AS "fullName", + st.primary_role AS "primaryRole", + st.average_rating AS "averageRating", + st.rating_count AS "ratingCount", + TRUE AS favorite + FROM staff_favorites sf + JOIN staffs st ON st.id = sf.staff_id + WHERE sf.tenant_id = $1 + AND sf.business_id = $2 + ORDER BY st.average_rating DESC, st.full_name ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function getOrderReorderPreview(actorUid, orderId) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + o.id AS "orderId", + o.title, + o.description, + o.starts_at AS "startsAt", + o.ends_at AS "endsAt", + o.location_name AS "locationName", + o.location_address AS "locationAddress", + o.metadata, + json_agg( + json_build_object( + 'shiftId', s.id, + 'shiftCode', s.shift_code, + 'title', s.title, + 'startsAt', s.starts_at, + 'endsAt', s.ends_at, + 'roles', ( + SELECT json_agg( + json_build_object( + 'roleId', sr.id, + 'roleCode', sr.role_code, + 'roleName', sr.role_name, + 'workersNeeded', sr.workers_needed, + 'payRateCents', sr.pay_rate_cents, + 'billRateCents', sr.bill_rate_cents + ) + ORDER BY sr.role_name ASC + ) + FROM shift_roles sr + WHERE sr.shift_id = s.id + ) + ) + ORDER BY s.starts_at ASC + ) AS shifts + FROM orders o + JOIN shifts s ON s.order_id = o.id + WHERE o.tenant_id = $1 + AND o.business_id = $2 + AND o.id = $3 + GROUP BY o.id + `, + [context.tenant.tenantId, context.business.businessId, orderId] + ); + if (result.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found for reorder preview', 404, { orderId }); + } + return result.rows[0]; +} + +export async function listBusinessTeamMembers(actorUid) { + const context = await requireClientContext(actorUid); + const result = await query( + ` + SELECT + bm.id AS "businessMembershipId", + u.id AS "userId", + COALESCE(u.display_name, u.email) AS name, + u.email, + bm.business_role AS role + FROM business_memberships bm + JOIN users u ON u.id = bm.user_id + WHERE bm.tenant_id = $1 + AND bm.business_id = $2 + AND bm.membership_status = 'ACTIVE' + ORDER BY name ASC + `, + [context.tenant.tenantId, context.business.businessId] + ); + return result.rows; +} + +export async function getReportSummary(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const [shifts, spend, performance, noShow] = await Promise.all([ + query( + ` + SELECT + COUNT(DISTINCT s.id)::INTEGER AS "totalShifts", + COALESCE(AVG( + CASE WHEN s.required_workers = 0 THEN 1 + ELSE LEAST(s.assigned_workers::numeric / s.required_workers, 1) + END + ), 0)::NUMERIC(8,4) AS "averageCoverage" + FROM shifts s + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS "averagePerformanceScore" + FROM staff_reviews + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT COUNT(*)::INTEGER AS "noShowCount" + FROM assignments + WHERE tenant_id = $1 + AND business_id = $2 + AND status = 'NO_SHOW' + AND updated_at >= $3::timestamptz + AND updated_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + ]); + + return { + totalShifts: Number(shifts.rows[0]?.totalShifts || 0), + totalSpendCents: Number(spend.rows[0]?.totalSpendCents || 0), + averageCoveragePercentage: Math.round(Number(shifts.rows[0]?.averageCoverage || 0) * 100), + averagePerformanceScore: Number(performance.rows[0]?.averagePerformanceScore || 0), + noShowCount: Number(noShow.rows[0]?.noShowCount || 0), + forecastAccuracyPercentage: 90, + }; +} + +export async function getDailyOpsReport(actorUid, { date }) { + const context = await requireClientContext(actorUid); + const from = startOfDay(date).toISOString(); + const to = endOfDay(date).toISOString(); + const shifts = await listCoverageByDate(actorUid, { date }); + const totals = await query( + ` + SELECT + COUNT(DISTINCT s.id)::INTEGER AS "totalShifts", + COUNT(DISTINCT a.id)::INTEGER AS "totalWorkersDeployed", + COALESCE(SUM(ts.regular_minutes + ts.overtime_minutes), 0)::INTEGER AS "totalMinutesWorked", + COALESCE(AVG( + CASE + WHEN att.check_in_at IS NULL THEN 0 + WHEN att.check_in_at <= s.starts_at THEN 1 + ELSE 0 + END + ), 0)::NUMERIC(8,4) AS "onTimeArrivalRate" + FROM shifts s + LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN attendance_sessions att ON att.assignment_id = a.id + LEFT JOIN timesheets ts ON ts.assignment_id = a.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at < $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, from, to] + ); + return { + totalShifts: Number(totals.rows[0]?.totalShifts || 0), + totalWorkersDeployed: Number(totals.rows[0]?.totalWorkersDeployed || 0), + totalHoursWorked: Math.round(Number(totals.rows[0]?.totalMinutesWorked || 0) / 60), + onTimeArrivalPercentage: Math.round(Number(totals.rows[0]?.onTimeArrivalRate || 0) * 100), + shifts, + }; +} + +export async function getSpendReport(actorUid, { startDate, endDate, bucket = 'day' }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const bucketExpr = bucket === 'week' ? 'week' : 'day'; + const [total, chart, breakdown] = await Promise.all([ + query( + ` + SELECT COALESCE(SUM(total_cents), 0)::BIGINT AS "totalSpendCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + query( + ` + SELECT + date_trunc('${bucketExpr}', created_at) AS bucket, + COALESCE(SUM(total_cents), 0)::BIGINT AS "amountCents" + FROM invoices + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + GROUP BY 1 + ORDER BY 1 ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ), + getSpendBreakdown(actorUid, { startDate, endDate }), + ]); + return { + totalSpendCents: Number(total.rows[0]?.totalSpendCents || 0), + chart: chart.rows, + breakdown, + }; +} + +export async function getCoverageReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const result = await query( + ` + WITH daily AS ( + SELECT + date_trunc('day', starts_at) AS day, + SUM(required_workers)::INTEGER AS needed, + SUM(assigned_workers)::INTEGER AS filled + FROM shifts + WHERE tenant_id = $1 + AND business_id = $2 + AND starts_at >= $3::timestamptz + AND starts_at <= $4::timestamptz + GROUP BY 1 + ) + SELECT + day, + needed, + filled, + CASE WHEN needed = 0 THEN 0 + ELSE ROUND((filled::numeric / needed) * 100, 2) + END AS "coveragePercentage" + FROM daily + ORDER BY day ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const totals = result.rows.reduce((acc, row) => { + acc.neededWorkers += Number(row.needed || 0); + acc.filledWorkers += Number(row.filled || 0); + return acc; + }, { neededWorkers: 0, filledWorkers: 0 }); + return { + averageCoveragePercentage: totals.neededWorkers === 0 + ? 0 + : Math.round((totals.filledWorkers / totals.neededWorkers) * 100), + filledWorkers: totals.filledWorkers, + neededWorkers: totals.neededWorkers, + chart: result.rows, + }; +} + +export async function getForecastReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 42); + const weekly = await query( + ` + SELECT + date_trunc('week', s.starts_at) AS week, + COUNT(DISTINCT s.id)::INTEGER AS "shiftCount", + COALESCE(SUM(sr.workers_needed * EXTRACT(EPOCH FROM (s.ends_at - s.starts_at)) / 3600), 0)::NUMERIC(12,2) AS "workerHours", + COALESCE(SUM(sr.bill_rate_cents * sr.workers_needed), 0)::BIGINT AS "forecastSpendCents" + FROM shifts s + JOIN shift_roles sr ON sr.shift_id = s.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + GROUP BY 1 + ORDER BY 1 ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const totals = weekly.rows.reduce((acc, row) => { + acc.forecastSpendCents += Number(row.forecastSpendCents || 0); + acc.totalShifts += Number(row.shiftCount || 0); + acc.totalWorkerHours += Number(row.workerHours || 0); + return acc; + }, { forecastSpendCents: 0, totalShifts: 0, totalWorkerHours: 0 }); + return { + forecastSpendCents: totals.forecastSpendCents, + averageWeeklySpendCents: weekly.rows.length === 0 ? 0 : Math.round(totals.forecastSpendCents / weekly.rows.length), + totalShifts: totals.totalShifts, + totalWorkerHours: totals.totalWorkerHours, + weeks: weekly.rows.map((row) => ({ + ...row, + averageShiftCostCents: Number(row.shiftCount || 0) === 0 ? 0 : Math.round(Number(row.forecastSpendCents || 0) / Number(row.shiftCount || 0)), + })), + }; +} + +export async function getPerformanceReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const totals = await query( + ` + WITH base AS ( + SELECT + COUNT(DISTINCT s.id)::INTEGER AS total_shifts, + COUNT(DISTINCT s.id) FILTER (WHERE s.assigned_workers >= s.required_workers)::INTEGER AS filled_shifts, + COUNT(DISTINCT s.id) FILTER (WHERE s.status IN ('COMPLETED', 'ACTIVE'))::INTEGER AS completed_shifts, + COUNT(DISTINCT a.id) FILTER ( + WHERE att.check_in_at IS NOT NULL AND att.check_in_at <= s.starts_at + )::INTEGER AS on_time_assignments, + COUNT(DISTINCT a.id)::INTEGER AS total_assignments, + COUNT(DISTINCT a.id) FILTER (WHERE a.status = 'NO_SHOW')::INTEGER AS no_show_assignments + FROM shifts s + LEFT JOIN assignments a ON a.shift_id = s.id + LEFT JOIN attendance_sessions att ON att.assignment_id = a.id + WHERE s.tenant_id = $1 + AND s.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + ), + fill_times AS ( + SELECT COALESCE(AVG(EXTRACT(EPOCH FROM (a.assigned_at - s.created_at)) / 60), 0)::NUMERIC(12,2) AS avg_fill_minutes + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + WHERE a.tenant_id = $1 + AND a.business_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + ), + reviews AS ( + SELECT COALESCE(AVG(rating), 0)::NUMERIC(8,4) AS avg_rating + FROM staff_reviews + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + ) + SELECT * + FROM base, fill_times, reviews + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const row = totals.rows[0] || {}; + const totalShifts = Number(row.total_shifts || 0); + const totalAssignments = Number(row.total_assignments || 0); + return { + averagePerformanceScore: Number(row.avg_rating || 0), + fillRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.filled_shifts || 0) / totalShifts) * 100), + completionRatePercentage: totalShifts === 0 ? 0 : Math.round((Number(row.completed_shifts || 0) / totalShifts) * 100), + onTimeRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.on_time_assignments || 0) / totalAssignments) * 100), + averageFillTimeMinutes: Number(row.avg_fill_minutes || 0), + totalShiftsCovered: Number(row.completed_shifts || 0), + noShowRatePercentage: totalAssignments === 0 ? 0 : Math.round((Number(row.no_show_assignments || 0) / totalAssignments) * 100), + }; +} + +export async function getNoShowReport(actorUid, { startDate, endDate }) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 30); + const incidents = await query( + ` + SELECT + st.id AS "staffId", + st.full_name AS "staffName", + COUNT(*)::INTEGER AS "incidentCount", + json_agg( + json_build_object( + 'shiftId', s.id, + 'shiftTitle', s.title, + 'roleName', sr.role_name, + 'date', s.starts_at + ) + ORDER BY s.starts_at DESC + ) AS incidents + FROM assignments a + JOIN staffs st ON st.id = a.staff_id + JOIN shifts s ON s.id = a.shift_id + JOIN shift_roles sr ON sr.id = a.shift_role_id + WHERE a.tenant_id = $1 + AND a.business_id = $2 + AND a.status = 'NO_SHOW' + AND s.starts_at >= $3::timestamptz + AND s.starts_at <= $4::timestamptz + GROUP BY st.id + ORDER BY "incidentCount" DESC, "staffName" ASC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + const totalNoShowCount = incidents.rows.reduce((acc, row) => acc + Number(row.incidentCount || 0), 0); + const totalWorkers = incidents.rows.length; + const totalAssignments = await query( + ` + SELECT COUNT(*)::INTEGER AS total + FROM assignments + WHERE tenant_id = $1 + AND business_id = $2 + AND created_at >= $3::timestamptz + AND created_at <= $4::timestamptz + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end] + ); + return { + totalNoShowCount, + noShowRatePercentage: Number(totalAssignments.rows[0]?.total || 0) === 0 + ? 0 + : Math.round((totalNoShowCount / Number(totalAssignments.rows[0].total)) * 100), + workersWhoNoShowed: totalWorkers, + items: incidents.rows.map((row) => ({ + ...row, + riskStatus: Number(row.incidentCount || 0) >= 2 ? 'HIGH' : 'MEDIUM', + })), + }; +} + +export async function listGeofenceIncidents(actorUid, { startDate, endDate, status } = {}) { + const context = await requireClientContext(actorUid); + const range = parseDateRange(startDate, endDate, 14); + const result = await query( + ` + SELECT + gi.id AS "incidentId", + gi.assignment_id AS "assignmentId", + gi.shift_id AS "shiftId", + st.full_name AS "staffName", + gi.incident_type AS "incidentType", + gi.severity, + gi.status, + gi.effective_clock_in_mode AS "clockInMode", + gi.override_reason AS "overrideReason", + gi.message, + gi.distance_to_clock_point_meters AS "distanceToClockPointMeters", + gi.within_geofence AS "withinGeofence", + gi.occurred_at AS "occurredAt" + FROM geofence_incidents gi + LEFT JOIN staffs st ON st.id = gi.staff_id + WHERE gi.tenant_id = $1 + AND gi.business_id = $2 + AND gi.occurred_at >= $3::timestamptz + AND gi.occurred_at <= $4::timestamptz + AND ($5::text IS NULL OR gi.status = $5) + ORDER BY gi.occurred_at DESC + `, + [context.tenant.tenantId, context.business.businessId, range.start, range.end, status || null] + ); + return result.rows; +} + +export async function listEmergencyContacts(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + id AS "contactId", + full_name AS "fullName", + phone, + relationship_type AS "relationshipType", + is_primary AS "isPrimary" + FROM emergency_contacts + WHERE tenant_id = $1 + AND staff_id = $2 + ORDER BY is_primary DESC, created_at ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listTaxForms(actorUid) { + const context = await requireStaffContext(actorUid); + const docs = ['I-9', 'W-4']; + const result = await query( + ` + SELECT + d.id AS "documentId", + d.name AS "formType", + sd.id AS "staffDocumentId", + COALESCE(sd.metadata->>'formStatus', 'NOT_STARTED') AS status, + COALESCE(sd.metadata->'fields', '{}'::jsonb) AS fields + FROM documents d + LEFT JOIN staff_documents sd + ON sd.document_id = d.id + AND sd.staff_id = $2 + AND sd.tenant_id = $1 + WHERE d.tenant_id = $1 + AND d.document_type = 'TAX_FORM' + AND d.name = ANY($3::text[]) + ORDER BY d.name ASC + `, + [context.tenant.tenantId, context.staff.staffId, docs] + ); + return result.rows; +} + +export async function listAttireChecklist(actorUid) { + const context = await requireStaffContext(actorUid); + const result = await query( + ` + SELECT + d.id AS "documentId", + d.name, + COALESCE(d.metadata->>'description', '') AS description, + COALESCE((d.metadata->>'required')::boolean, TRUE) AS mandatory, + sd.id AS "staffDocumentId", + sd.file_uri AS "photoUri", + COALESCE(sd.status, 'NOT_UPLOADED') AS status, + sd.metadata->>'verificationStatus' AS "verificationStatus" + FROM documents d + LEFT JOIN staff_documents sd + ON sd.document_id = d.id + AND sd.staff_id = $2 + AND sd.tenant_id = $1 + WHERE d.tenant_id = $1 + AND d.document_type = 'ATTIRE' + ORDER BY d.name ASC + `, + [context.tenant.tenantId, context.staff.staffId] + ); + return result.rows; +} + +export async function listTimeCardEntries(actorUid, { month, year }) { + const context = await requireStaffContext(actorUid); + const monthValue = Number.parseInt(`${month || new Date().getUTCMonth() + 1}`, 10); + const yearValue = Number.parseInt(`${year || new Date().getUTCFullYear()}`, 10); + const start = new Date(Date.UTC(yearValue, monthValue - 1, 1)); + const end = new Date(Date.UTC(yearValue, monthValue, 1)); + const result = await query( + ` + SELECT + s.starts_at::date AS date, + s.title AS "shiftName", + COALESCE(cp.label, s.location_name) AS location, + att.check_in_at AS "clockInAt", + att.check_out_at AS "clockOutAt", + COALESCE(ts.regular_minutes + ts.overtime_minutes, 0) AS "minutesWorked", + sr.pay_rate_cents AS "hourlyRateCents", + COALESCE(ts.gross_pay_cents, 0)::BIGINT AS "totalPayCents" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + LEFT JOIN shift_roles sr ON sr.id = a.shift_role_id + LEFT JOIN attendance_sessions att ON att.assignment_id = a.id + LEFT JOIN timesheets ts ON ts.assignment_id = a.id + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE a.tenant_id = $1 + AND a.staff_id = $2 + AND s.starts_at >= $3::timestamptz + AND s.starts_at < $4::timestamptz + AND a.status IN ('CHECKED_OUT', 'COMPLETED') + ORDER BY s.starts_at DESC + `, + [context.tenant.tenantId, context.staff.staffId, start.toISOString(), end.toISOString()] + ); + return result.rows; +} + +export async function getPrivacySettings(actorUid) { + const context = await requireStaffContext(actorUid); + return { + profileVisible: metadataBoolean(context.staff.metadata || {}, 'profileVisible', true), + }; +} + +export async function listFaqCategories() { + return FAQ_CATEGORIES; +} + +export async function searchFaqs(queryText) { + const needle = `${queryText || ''}`.trim().toLowerCase(); + if (!needle) { + return FAQ_CATEGORIES; + } + return FAQ_CATEGORIES + .map((category) => ({ + category: category.category, + items: category.items.filter((item) => { + const haystack = `${item.question} ${item.answer}`.toLowerCase(); + return haystack.includes(needle); + }), + })) + .filter((category) => category.items.length > 0); +} diff --git a/backend/query-api/src/services/policy.js b/backend/query-api/src/services/policy.js new file mode 100644 index 00000000..44e7e371 --- /dev/null +++ b/backend/query-api/src/services/policy.js @@ -0,0 +1,5 @@ +export function can(action, resource, actor) { + void action; + void resource; + return Boolean(actor?.uid); +} diff --git a/backend/query-api/src/services/query-service.js b/backend/query-api/src/services/query-service.js new file mode 100644 index 00000000..02a5e795 --- /dev/null +++ b/backend/query-api/src/services/query-service.js @@ -0,0 +1,285 @@ +import { AppError } from '../lib/errors.js'; +import { query } from './db.js'; + +function parseLimit(value, fallback = 20, max = 100) { + const parsed = Number.parseInt(`${value || fallback}`, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return Math.min(parsed, max); +} + +function parseOffset(value) { + const parsed = Number.parseInt(`${value || 0}`, 10); + if (!Number.isFinite(parsed) || parsed < 0) return 0; + return parsed; +} + +export async function listOrders({ tenantId, businessId, status, limit, offset }) { + const result = await query( + ` + SELECT + o.id, + o.order_number AS "orderNumber", + o.title, + o.status, + o.service_type AS "serviceType", + o.starts_at AS "startsAt", + o.ends_at AS "endsAt", + o.location_name AS "locationName", + o.location_address AS "locationAddress", + o.created_at AS "createdAt", + b.id AS "businessId", + b.business_name AS "businessName", + v.id AS "vendorId", + v.company_name AS "vendorName", + COALESCE(COUNT(s.id), 0)::INTEGER AS "shiftCount", + COALESCE(SUM(s.required_workers), 0)::INTEGER AS "requiredWorkers", + COALESCE(SUM(s.assigned_workers), 0)::INTEGER AS "assignedWorkers" + FROM orders o + JOIN businesses b ON b.id = o.business_id + LEFT JOIN vendors v ON v.id = o.vendor_id + LEFT JOIN shifts s ON s.order_id = o.id + WHERE o.tenant_id = $1 + AND ($2::uuid IS NULL OR o.business_id = $2::uuid) + AND ($3::text IS NULL OR o.status = $3::text) + GROUP BY o.id, b.id, v.id + ORDER BY o.created_at DESC + LIMIT $4 OFFSET $5 + `, + [ + tenantId, + businessId || null, + status || null, + parseLimit(limit), + parseOffset(offset), + ] + ); + + return result.rows; +} + +export async function getOrderDetail({ tenantId, orderId }) { + const orderResult = await query( + ` + SELECT + o.id, + o.order_number AS "orderNumber", + o.title, + o.description, + o.status, + o.service_type AS "serviceType", + o.starts_at AS "startsAt", + o.ends_at AS "endsAt", + o.location_name AS "locationName", + o.location_address AS "locationAddress", + o.latitude, + o.longitude, + o.notes, + o.created_at AS "createdAt", + b.id AS "businessId", + b.business_name AS "businessName", + v.id AS "vendorId", + v.company_name AS "vendorName" + FROM orders o + JOIN businesses b ON b.id = o.business_id + LEFT JOIN vendors v ON v.id = o.vendor_id + WHERE o.tenant_id = $1 + AND o.id = $2 + `, + [tenantId, orderId] + ); + + if (orderResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Order not found', 404, { tenantId, orderId }); + } + + const shiftsResult = await query( + ` + SELECT + s.id, + s.shift_code AS "shiftCode", + s.title, + s.status, + s.starts_at AS "startsAt", + s.ends_at AS "endsAt", + s.timezone, + s.location_name AS "locationName", + s.location_address AS "locationAddress", + s.required_workers AS "requiredWorkers", + s.assigned_workers AS "assignedWorkers", + cp.id AS "clockPointId", + cp.label AS "clockPointLabel" + FROM shifts s + LEFT JOIN clock_points cp ON cp.id = s.clock_point_id + WHERE s.tenant_id = $1 + AND s.order_id = $2 + ORDER BY s.starts_at ASC + `, + [tenantId, orderId] + ); + + const shiftIds = shiftsResult.rows.map((row) => row.id); + let rolesByShiftId = new Map(); + + if (shiftIds.length > 0) { + const rolesResult = await query( + ` + SELECT + sr.id, + sr.shift_id AS "shiftId", + sr.role_code AS "roleCode", + sr.role_name AS "roleName", + sr.workers_needed AS "workersNeeded", + sr.assigned_count AS "assignedCount", + sr.pay_rate_cents AS "payRateCents", + sr.bill_rate_cents AS "billRateCents" + FROM shift_roles sr + WHERE sr.shift_id = ANY($1::uuid[]) + ORDER BY sr.role_name ASC + `, + [shiftIds] + ); + rolesByShiftId = rolesResult.rows.reduce((map, row) => { + const list = map.get(row.shiftId) || []; + list.push(row); + map.set(row.shiftId, list); + return map; + }, new Map()); + } + + return { + ...orderResult.rows[0], + shifts: shiftsResult.rows.map((shift) => ({ + ...shift, + roles: rolesByShiftId.get(shift.id) || [], + })), + }; +} + +export async function listFavoriteStaff({ tenantId, businessId, limit, offset }) { + const result = await query( + ` + SELECT + sf.id AS "favoriteId", + sf.created_at AS "favoritedAt", + s.id AS "staffId", + s.full_name AS "fullName", + s.primary_role AS "primaryRole", + s.average_rating AS "averageRating", + s.rating_count AS "ratingCount", + s.status + FROM staff_favorites sf + JOIN staffs s ON s.id = sf.staff_id + WHERE sf.tenant_id = $1 + AND sf.business_id = $2 + ORDER BY sf.created_at DESC + LIMIT $3 OFFSET $4 + `, + [tenantId, businessId, parseLimit(limit), parseOffset(offset)] + ); + return result.rows; +} + +export async function getStaffReviewSummary({ tenantId, staffId, limit }) { + const staffResult = await query( + ` + SELECT + id AS "staffId", + full_name AS "fullName", + average_rating AS "averageRating", + rating_count AS "ratingCount", + primary_role AS "primaryRole", + status + FROM staffs + WHERE tenant_id = $1 + AND id = $2 + `, + [tenantId, staffId] + ); + + if (staffResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Staff not found', 404, { tenantId, staffId }); + } + + const reviewsResult = await query( + ` + SELECT + sr.id AS "reviewId", + sr.rating, + sr.review_text AS "reviewText", + sr.tags, + sr.created_at AS "createdAt", + b.id AS "businessId", + b.business_name AS "businessName", + sr.assignment_id AS "assignmentId" + FROM staff_reviews sr + JOIN businesses b ON b.id = sr.business_id + WHERE sr.tenant_id = $1 + AND sr.staff_id = $2 + ORDER BY sr.created_at DESC + LIMIT $3 + `, + [tenantId, staffId, parseLimit(limit, 10, 50)] + ); + + return { + ...staffResult.rows[0], + reviews: reviewsResult.rows, + }; +} + +export async function getAssignmentAttendance({ tenantId, assignmentId }) { + const assignmentResult = await query( + ` + SELECT + a.id AS "assignmentId", + a.status, + a.shift_id AS "shiftId", + a.staff_id AS "staffId", + s.title AS "shiftTitle", + s.starts_at AS "shiftStartsAt", + s.ends_at AS "shiftEndsAt", + attendance_sessions.id AS "sessionId", + attendance_sessions.status AS "sessionStatus", + attendance_sessions.check_in_at AS "checkInAt", + attendance_sessions.check_out_at AS "checkOutAt", + attendance_sessions.worked_minutes AS "workedMinutes" + FROM assignments a + JOIN shifts s ON s.id = a.shift_id + LEFT JOIN attendance_sessions ON attendance_sessions.assignment_id = a.id + WHERE a.id = $1 + AND a.tenant_id = $2 + `, + [assignmentId, tenantId] + ); + + if (assignmentResult.rowCount === 0) { + throw new AppError('NOT_FOUND', 'Assignment not found', 404, { tenantId, assignmentId }); + } + + const eventsResult = await query( + ` + SELECT + id AS "attendanceEventId", + event_type AS "eventType", + source_type AS "sourceType", + source_reference AS "sourceReference", + nfc_tag_uid AS "nfcTagUid", + latitude, + longitude, + distance_to_clock_point_meters AS "distanceToClockPointMeters", + within_geofence AS "withinGeofence", + validation_status AS "validationStatus", + validation_reason AS "validationReason", + captured_at AS "capturedAt" + FROM attendance_events + WHERE assignment_id = $1 + ORDER BY captured_at ASC + `, + [assignmentId] + ); + + return { + ...assignmentResult.rows[0], + events: eventsResult.rows, + }; +} diff --git a/backend/query-api/test/app.test.js b/backend/query-api/test/app.test.js new file mode 100644 index 00000000..f2a5e9d7 --- /dev/null +++ b/backend/query-api/test/app.test.js @@ -0,0 +1,126 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; + +process.env.AUTH_BYPASS = 'true'; + +const tenantId = '11111111-1111-4111-8111-111111111111'; +const orderId = '22222222-2222-4222-8222-222222222222'; +const businessId = '33333333-3333-4333-8333-333333333333'; +const staffId = '44444444-4444-4444-8444-444444444444'; +const assignmentId = '55555555-5555-4555-8555-555555555555'; + +test('GET /healthz returns healthy response', async () => { + const app = createApp(); + const res = await request(app).get('/healthz'); + + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(res.body.service, 'krow-query-api'); + assert.equal(typeof res.body.requestId, 'string'); + assert.equal(typeof res.headers['x-request-id'], 'string'); +}); + +test('GET /readyz reports database not configured when no database env is present', async () => { + delete process.env.DATABASE_URL; + delete process.env.DB_HOST; + delete process.env.DB_NAME; + delete process.env.DB_USER; + delete process.env.DB_PASSWORD; + delete process.env.INSTANCE_CONNECTION_NAME; + + const app = createApp(); + const res = await request(app).get('/readyz'); + + assert.equal(res.status, 503); + assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); +}); + +test('GET unknown route returns not found envelope', async () => { + const app = createApp(); + const res = await request(app).get('/query/unknown'); + + assert.equal(res.status, 404); + assert.equal(res.body.code, 'NOT_FOUND'); + assert.equal(typeof res.body.requestId, 'string'); +}); + +test('GET /query/tenants/:tenantId/orders returns injected query result', async () => { + const app = createApp({ + queryService: { + listOrders: async (params) => { + assert.equal(params.tenantId, tenantId); + return [{ + id: orderId, + orderNumber: 'ORD-1001', + title: 'Cafe Event Staffing', + status: 'OPEN', + }]; + }, + getOrderDetail: async () => assert.fail('getOrderDetail should not be called'), + listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'), + getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'), + getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'), + }, + }); + + const res = await request(app) + .get(`/query/tenants/${tenantId}/orders`) + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items.length, 1); + assert.equal(res.body.items[0].id, orderId); +}); + +test('GET /query/tenants/:tenantId/assignments/:assignmentId/attendance returns injected attendance', async () => { + const app = createApp({ + queryService: { + listOrders: async () => assert.fail('listOrders should not be called'), + getOrderDetail: async () => assert.fail('getOrderDetail should not be called'), + listFavoriteStaff: async () => assert.fail('listFavoriteStaff should not be called'), + getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'), + getAssignmentAttendance: async (params) => { + assert.equal(params.tenantId, tenantId); + assert.equal(params.assignmentId, assignmentId); + return { + assignmentId, + sessionStatus: 'OPEN', + events: [], + }; + }, + }, + }); + + const res = await request(app) + .get(`/query/tenants/${tenantId}/assignments/${assignmentId}/attendance`) + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.assignmentId, assignmentId); + assert.equal(res.body.sessionStatus, 'OPEN'); +}); + +test('GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff validates auth and handler wiring', async () => { + const app = createApp({ + queryService: { + listOrders: async () => assert.fail('listOrders should not be called'), + getOrderDetail: async () => assert.fail('getOrderDetail should not be called'), + listFavoriteStaff: async (params) => { + assert.equal(params.tenantId, tenantId); + assert.equal(params.businessId, businessId); + return [{ staffId, fullName: 'Ana Barista' }]; + }, + getStaffReviewSummary: async () => assert.fail('getStaffReviewSummary should not be called'), + getAssignmentAttendance: async () => assert.fail('getAssignmentAttendance should not be called'), + }, + }); + + const res = await request(app) + .get(`/query/tenants/${tenantId}/businesses/${businessId}/favorite-staff`) + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].staffId, staffId); +}); diff --git a/backend/query-api/test/mobile-routes.test.js b/backend/query-api/test/mobile-routes.test.js new file mode 100644 index 00000000..12c3d506 --- /dev/null +++ b/backend/query-api/test/mobile-routes.test.js @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; + +process.env.AUTH_BYPASS = 'true'; + +function createMobileQueryService() { + return { + getClientDashboard: async () => ({ businessName: 'Google Cafes' }), + getClientSession: async () => ({ business: { businessId: 'b1' } }), + getCoverageStats: async () => ({ totalCoveragePercentage: 100 }), + getCoverageReport: async () => ({ items: [{ shiftId: 'coverage-1' }] }), + getCurrentAttendanceStatus: async () => ({ attendanceStatus: 'NOT_CLOCKED_IN' }), + getCurrentBill: async () => ({ currentBillCents: 1000 }), + getDailyOpsReport: async () => ({ totals: { workedAssignments: 4 } }), + getForecastReport: async () => ({ totals: { projectedCoveragePercentage: 92 } }), + getNoShowReport: async () => ({ totals: { noShows: 1 } }), + getPaymentChart: async () => ([{ amountCents: 100 }]), + getPaymentsSummary: async () => ({ totalEarningsCents: 500 }), + getPersonalInfo: async () => ({ firstName: 'Ana' }), + getPerformanceReport: async () => ({ totals: { averageRating: 4.8 } }), + getProfileSectionsStatus: async () => ({ personalInfoCompleted: true }), + getPrivacySettings: async () => ({ profileVisibility: 'TEAM_ONLY' }), + getReportSummary: async () => ({ reportDate: '2026-03-13', totals: { orders: 3 } }), + getSavings: async () => ({ savingsCents: 200 }), + getSpendReport: async () => ({ totals: { amountCents: 2000 } }), + getSpendBreakdown: async () => ([{ category: 'Barista', amountCents: 1000 }]), + getStaffDashboard: async () => ({ staffName: 'Ana Barista' }), + getStaffProfileCompletion: async () => ({ completed: true }), + getStaffSession: async () => ({ staff: { staffId: 's1' } }), + getStaffShiftDetail: async () => ({ shiftId: 'shift-1' }), + listAssignedShifts: async () => ([{ shiftId: 'assigned-1' }]), + listBusinessAccounts: async () => ([{ accountId: 'acc-1' }]), + listCancelledShifts: async () => ([{ shiftId: 'cancelled-1' }]), + listCertificates: async () => ([{ certificateId: 'cert-1' }]), + listCostCenters: async () => ([{ costCenterId: 'cc-1' }]), + listCoverageByDate: async () => ([{ shiftId: 'coverage-1' }]), + listCoreTeam: async () => ([{ staffId: 'core-1' }]), + listCompletedShifts: async () => ([{ shiftId: 'completed-1' }]), + listEmergencyContacts: async () => ([{ contactId: 'ec-1' }]), + listFaqCategories: async () => ([{ id: 'faq-1', title: 'Clock in' }]), + listGeofenceIncidents: async () => ([{ incidentId: 'incident-1' }]), + listHubManagers: async () => ([{ managerId: 'm1' }]), + listHubs: async () => ([{ hubId: 'hub-1' }]), + listIndustries: async () => (['CATERING']), + listInvoiceHistory: async () => ([{ invoiceId: 'inv-1' }]), + listOpenShifts: async () => ([{ shiftId: 'open-1' }]), + getOrderReorderPreview: async () => ({ orderId: 'order-1', lines: 2 }), + listOrderItemsByDateRange: async () => ([{ itemId: 'item-1' }]), + listPaymentsHistory: async () => ([{ paymentId: 'pay-1' }]), + listPendingAssignments: async () => ([{ assignmentId: 'asg-1' }]), + listPendingInvoices: async () => ([{ invoiceId: 'pending-1' }]), + listProfileDocuments: async () => ([{ staffDocumentId: 'doc-1' }]), + listRecentReorders: async () => ([{ id: 'order-1' }]), + listBusinessTeamMembers: async () => ([{ userId: 'u-1' }]), + listSkills: async () => (['BARISTA']), + listStaffAvailability: async () => ([{ dayOfWeek: 1 }]), + listStaffBankAccounts: async () => ([{ accountId: 'acc-2' }]), + listStaffBenefits: async () => ([{ benefitId: 'benefit-1' }]), + listTaxForms: async () => ([{ formType: 'W4' }]), + listAttireChecklist: async () => ([{ documentId: 'attire-1' }]), + listTimeCardEntries: async () => ([{ entryId: 'tc-1' }]), + listTodayShifts: async () => ([{ shiftId: 'today-1' }]), + listVendorRoles: async () => ([{ roleId: 'role-1' }]), + listVendors: async () => ([{ vendorId: 'vendor-1' }]), + searchFaqs: async () => ([{ id: 'faq-2', title: 'Payments' }]), + }; +} + +test('GET /query/client/session returns injected client session', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/session') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.business.businessId, 'b1'); +}); + +test('GET /query/client/coverage validates date query param', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/coverage') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 400); + assert.equal(res.body.code, 'VALIDATION_ERROR'); +}); + +test('GET /query/staff/dashboard returns injected dashboard', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/dashboard') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.staffName, 'Ana Barista'); +}); + +test('GET /query/staff/shifts/:shiftId returns injected shift detail', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/shifts/shift-1') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.shiftId, 'shift-1'); +}); + +test('GET /query/client/reports/summary returns injected report summary', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/reports/summary?date=2026-03-13') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.totals.orders, 3); +}); + +test('GET /query/client/coverage/core-team returns injected core team list', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/coverage/core-team?date=2026-03-13') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].staffId, 'core-1'); +}); + +test('GET /query/client/coverage/incidents returns injected incidents list', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/client/coverage/incidents?startDate=2026-03-01&endDate=2026-03-16') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].incidentId, 'incident-1'); +}); + +test('GET /query/staff/profile/tax-forms returns injected tax forms', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/profile/tax-forms') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].formType, 'W4'); +}); + +test('GET /query/staff/faqs/search returns injected faq search results', async () => { + const app = createApp({ mobileQueryService: createMobileQueryService() }); + const res = await request(app) + .get('/query/staff/faqs/search?q=payments') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.items[0].title, 'Payments'); +}); diff --git a/backend/unified-api/Dockerfile b/backend/unified-api/Dockerfile new file mode 100644 index 00000000..55a6a26b --- /dev/null +++ b/backend/unified-api/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY src ./src + +ENV PORT=8080 +EXPOSE 8080 + +CMD ["node", "src/server.js"] diff --git a/backend/unified-api/package-lock.json b/backend/unified-api/package-lock.json new file mode 100644 index 00000000..43c1ab9c --- /dev/null +++ b/backend/unified-api/package-lock.json @@ -0,0 +1,3661 @@ +{ + "name": "@krow/unified-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@krow/unified-api", + "version": "0.1.0", + "dependencies": { + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "pg": "^8.20.0", + "pino": "^9.6.0", + "pino-http": "^10.3.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "supertest": "^7.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz", + "integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.7.1.tgz", + "integrity": "sha512-mFzsm7CLHR60o08S23iLUY8m/i6kLpOK87wdEFPLhdlCahaxKmWOwSVGiWoENYSmFJJoDhrR3gKSCxz7ENdIww==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.1.1.tgz", + "integrity": "sha512-LwIXe8+mVHY5LBPulWECOOIEXDiatyECp/BOlu0gOhe+WOcKjWHROaCbLlkFTgHMY7RHr5MOxkLP/tltWAH3dA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.7.1", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.1.1.tgz", + "integrity": "sha512-heAEVZ9Z8c8PnBUcmGh91JHX0cXcVa1yESW/xkLuwaX7idRFyLiN8sl73KXpR8ZArGoPXVQDanBnk6SQiekRCQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.7.1", + "@firebase/database": "1.1.1", + "@firebase/database-types": "1.0.17", + "@firebase/logger": "0.5.0", + "@firebase/util": "1.14.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.17.tgz", + "integrity": "sha512-4eWaM5fW3qEIHjGzfi3cf0Jpqi1xQsAdT6rSDE1RZPrWu8oGjgrq6ybMjobtyHQFgwGCykBm4YM89qDzc+uG/w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.14.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.5.0.tgz", + "integrity": "sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.14.0.tgz", + "integrity": "sha512-/gnejm7MKkVIXnSJGpc9L2CvvvzJvtDPeAEq5jAwgVlf/PeNxot+THx/bpD20wQ8uL5sz0xqgXy1nisOYMU+mw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.6", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.6.tgz", + "integrity": "sha512-EW/O8ktzwLfyWBOsNuhRoMi8lrC3clHM5LVFhGvO1HCsLozCOOXRAlHrYBoE6HL42Sc8yYMuCb2XqcnJ4OOEpw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.19.0.tgz", + "integrity": "sha512-n2FjE7NAOYyshogdc7KQOl/VZb4sneqPjWouSyia9CMDdMhRX5+RIbqalNmC7LOLzuLAN89VlF2HvG8na9G+zQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^5.3.4", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", + "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.3", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause", + "optional": true + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/request": { + "version": "2.48.13", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.13.tgz", + "integrity": "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.5" + } + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-builder": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.3.tgz", + "integrity": "sha512-1o60KoFw2+LWKQu3IdcfcFlGTW4dpqEWmjhYec6H82AYZU2TVBXep6tMl8Z1Y+wM+ZrzCwe3BZ9Vyd9N2rIvmg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.5.tgz", + "integrity": "sha512-NLY+V5NNbdmiEszx9n14mZBseJTC50bRq1VHsaxOmR72JDuZt+5J1Co+dC/4JPnyq+WrIHNM69r0sqf7BMb3Mg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "fast-xml-builder": "^1.1.3", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/firebase-admin": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-13.7.0.tgz", + "integrity": "sha512-o3qS8zCJbApe7aKzkO2Pa380t9cHISqeSd3blqYTtOuUUUua3qZTLwNWgGUOss3td6wbzrZhiHIj3c8+fC046Q==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "^2.0.0", + "@firebase/database-types": "^1.0.6", + "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", + "google-auth-library": "^10.6.1", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^11.0.2" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.11.0", + "@google-cloud/storage": "^7.19.0" + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.1.tgz", + "integrity": "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "7.1.3", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/gaxios": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.3.tgz", + "integrity": "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2", + "rimraf": "^5.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-auth-library/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.2.tgz", + "integrity": "sha512-BqTyEDV+lS8F2trk3A+qJnxV5Q9EqKCBJOPti3W97r7qTympCZjb7h2X6f2kc+0K3rsSTY1/6YG2eaXKoj497w==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz", + "integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-http": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/pino-http/-/pino-http-10.5.0.tgz", + "integrity": "sha512-hD91XjgaKkSsdn8P7LaebrNzhGTdB086W3pyPihX0EzGPjq5uBJBXo4N5guqNaK6mUjg9aubMF7wDViYek9dRA==", + "license": "MIT", + "dependencies": { + "get-caller-file": "^2.0.5", + "pino": "^9.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/backend/unified-api/package.json b/backend/unified-api/package.json new file mode 100644 index 00000000..6556c3ba --- /dev/null +++ b/backend/unified-api/package.json @@ -0,0 +1,24 @@ +{ + "name": "@krow/unified-api", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "start": "node src/server.js", + "test": "node --test" + }, + "dependencies": { + "express": "^4.21.2", + "firebase-admin": "^13.0.2", + "pg": "^8.20.0", + "pino": "^9.6.0", + "pino-http": "^10.3.0", + "zod": "^3.24.2" + }, + "devDependencies": { + "supertest": "^7.0.0" + } +} diff --git a/backend/unified-api/scripts/ensure-v2-demo-users.mjs b/backend/unified-api/scripts/ensure-v2-demo-users.mjs new file mode 100644 index 00000000..d5ea6cc1 --- /dev/null +++ b/backend/unified-api/scripts/ensure-v2-demo-users.mjs @@ -0,0 +1,64 @@ +import { signInWithPassword, signUpWithPassword } from '../src/services/identity-toolkit.js'; + +const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com'; +const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com'; +const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!'; +const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!'; + +async function ensureUser({ email, password, displayName }) { + try { + const signedIn = await signInWithPassword({ email, password }); + return { + uid: signedIn.localId, + email, + password, + created: false, + displayName, + }; + } catch (error) { + const message = error?.message || ''; + if (!message.includes('INVALID_LOGIN_CREDENTIALS') && !message.includes('EMAIL_NOT_FOUND')) { + throw error; + } + } + + try { + const signedUp = await signUpWithPassword({ email, password }); + return { + uid: signedUp.localId, + email, + password, + created: true, + displayName, + }; + } catch (error) { + const message = error?.message || ''; + if (message.includes('EMAIL_EXISTS')) { + throw new Error(`Firebase user ${email} exists but password does not match expected demo password.`); + } + throw error; + } +} + +async function main() { + const owner = await ensureUser({ + email: ownerEmail, + password: ownerPassword, + displayName: 'Legendary Demo Owner V2', + }); + + const staff = await ensureUser({ + email: staffEmail, + password: staffPassword, + displayName: 'Ana Barista V2', + }); + + // eslint-disable-next-line no-console + console.log(JSON.stringify({ owner, staff }, null, 2)); +} + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exit(1); +}); diff --git a/backend/unified-api/scripts/live-smoke-v2-unified.mjs b/backend/unified-api/scripts/live-smoke-v2-unified.mjs new file mode 100644 index 00000000..b6cd2402 --- /dev/null +++ b/backend/unified-api/scripts/live-smoke-v2-unified.mjs @@ -0,0 +1,1140 @@ +import assert from 'node:assert/strict'; +import { signInWithPassword } from '../src/services/identity-toolkit.js'; +import { V2DemoFixture as fixture } from '../../command-api/scripts/v2-demo-fixture.mjs'; + +const unifiedBaseUrl = process.env.UNIFIED_API_BASE_URL || 'https://krow-api-v2-e3g6witsvq-uc.a.run.app'; +const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com'; +const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com'; +const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!'; +const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!'; + +function uniqueKey(prefix) { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +function isoDate(offsetDays = 0) { + const value = new Date(Date.now() + (offsetDays * 24 * 60 * 60 * 1000)); + return value.toISOString().slice(0, 10); +} + +function isoTimestamp(offsetHours = 0) { + return new Date(Date.now() + (offsetHours * 60 * 60 * 1000)).toISOString(); +} + +function logStep(step, payload) { + // eslint-disable-next-line no-console + console.log(`[unified-smoke-v2] ${step}: ${JSON.stringify(payload)}`); +} + +async function readJson(response) { + const text = await response.text(); + return text ? JSON.parse(text) : {}; +} + +async function apiCall(path, { + method = 'GET', + token, + idempotencyKey, + body, + expectedStatus = 200, +} = {}) { + const headers = {}; + if (token) headers.Authorization = `Bearer ${token}`; + if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + + const response = await fetch(`${unifiedBaseUrl}${path}`, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const payload = await readJson(response); + if (response.status !== expectedStatus) { + throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`); + } + return payload; +} + +async function uploadFile(path, token, { + filename, + contentType, + content, + fields = {}, + expectedStatus = 200, +}) { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) { + form.set(key, value); + } + form.set( + 'file', + new File([content], filename, { + type: contentType, + }) + ); + + const response = await fetch(`${unifiedBaseUrl}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + }, + body: form, + }); + const payload = await readJson(response); + if (response.status !== expectedStatus) { + throw new Error(`POST ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`); + } + return payload; +} + +async function signInClient() { + return apiCall('/auth/client/sign-in', { + method: 'POST', + body: { + email: ownerEmail, + password: ownerPassword, + }, + }); +} + +async function signInStaff() { + return signInWithPassword({ + email: staffEmail, + password: staffPassword, + }); +} + +async function main() { + const reportWindow = `startDate=${encodeURIComponent(isoTimestamp(-24 * 14))}&endDate=${encodeURIComponent(isoTimestamp(24 * 14))}`; + const ownerSession = await signInClient(); + const staffAuth = await signInStaff(); + + assert.ok(ownerSession.sessionToken); + assert.ok(staffAuth.idToken); + assert.equal(ownerSession.business.businessId, fixture.business.id); + logStep('auth.client.sign-in.ok', { + tenantId: ownerSession.tenant.tenantId, + businessId: ownerSession.business.businessId, + }); + logStep('auth.staff.password-sign-in.ok', { + uid: staffAuth.localId, + email: staffEmail, + }); + + const authSession = await apiCall('/auth/session', { + token: ownerSession.sessionToken, + }); + assert.equal(authSession.business.businessId, fixture.business.id); + logStep('auth.session.ok', authSession); + + const staffPhoneStart = await apiCall('/auth/staff/phone/start', { + method: 'POST', + body: { phoneNumber: fixture.staff.ana.phone }, + }); + assert.equal(staffPhoneStart.mode, 'CLIENT_FIREBASE_SDK'); + logStep('auth.staff.phone-start.ok', staffPhoneStart); + + const staffPhoneVerify = await apiCall('/auth/staff/phone/verify', { + method: 'POST', + body: { + mode: 'sign-in', + idToken: staffAuth.idToken, + }, + }); + assert.equal(staffPhoneVerify.staff.staffId, fixture.staff.ana.id); + logStep('auth.staff.phone-verify.ok', { + staffId: staffPhoneVerify.staff.staffId, + requiresProfileSetup: staffPhoneVerify.requiresProfileSetup, + }); + + const clientSession = await apiCall('/client/session', { + token: ownerSession.sessionToken, + }); + assert.equal(clientSession.business.businessId, fixture.business.id); + logStep('client.session.ok', clientSession); + + const clientPushTokenPrimary = await apiCall('/client/devices/push-tokens', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('client-push-token-primary'), + body: { + provider: 'FCM', + platform: 'IOS', + pushToken: `smoke-client-primary-${Date.now()}-abcdefghijklmnop`, + deviceId: 'smoke-client-iphone-15-pro', + appVersion: '2.0.0-smoke', + appBuild: '2000', + locale: 'en-US', + timezone: 'America/Los_Angeles', + }, + }); + assert.ok(clientPushTokenPrimary.tokenId); + logStep('client.push-token.register-primary.ok', clientPushTokenPrimary); + + const clientPushTokenCleanup = await apiCall('/client/devices/push-tokens', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('client-push-token-cleanup'), + body: { + provider: 'FCM', + platform: 'ANDROID', + pushToken: `smoke-client-cleanup-${Date.now()}-abcdefghijklmnop`, + deviceId: 'smoke-client-pixel-9', + appVersion: '2.0.0-smoke', + appBuild: '2001', + locale: 'en-US', + timezone: 'America/Los_Angeles', + }, + }); + assert.ok(clientPushTokenCleanup.tokenId); + logStep('client.push-token.register-cleanup.ok', clientPushTokenCleanup); + + const clientPushTokenDeleted = await apiCall(`/client/devices/push-tokens?tokenId=${encodeURIComponent(clientPushTokenCleanup.tokenId)}&reason=SMOKE_CLEANUP`, { + method: 'DELETE', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('client-push-token-delete'), + }); + assert.equal(clientPushTokenDeleted.removedCount, 1); + logStep('client.push-token.delete.ok', clientPushTokenDeleted); + + const clientDashboard = await apiCall('/client/dashboard', { + token: ownerSession.sessionToken, + }); + assert.equal(clientDashboard.businessId, fixture.business.id); + logStep('client.dashboard.ok', { + weeklySpendCents: clientDashboard.spending.weeklySpendCents, + openPositionsToday: clientDashboard.coverage.openPositionsToday, + }); + + const clientReorders = await apiCall('/client/reorders', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(clientReorders.items)); + logStep('client.reorders.ok', { count: clientReorders.items.length }); + + const billingAccounts = await apiCall('/client/billing/accounts', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(billingAccounts.items)); + logStep('client.billing.accounts.ok', { count: billingAccounts.items.length }); + + const pendingInvoices = await apiCall('/client/billing/invoices/pending', { + token: ownerSession.sessionToken, + }); + assert.ok(pendingInvoices.items.length >= 1); + const invoiceId = pendingInvoices.items[0].invoiceId; + logStep('client.billing.pending-invoices.ok', { count: pendingInvoices.items.length, invoiceId }); + + const invoiceHistory = await apiCall('/client/billing/invoices/history', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(invoiceHistory.items)); + logStep('client.billing.invoice-history.ok', { count: invoiceHistory.items.length }); + + const currentBill = await apiCall('/client/billing/current-bill', { + token: ownerSession.sessionToken, + }); + assert.ok(typeof currentBill.currentBillCents === 'number'); + logStep('client.billing.current-bill.ok', currentBill); + + const savings = await apiCall('/client/billing/savings', { + token: ownerSession.sessionToken, + }); + assert.ok(typeof savings.savingsCents === 'number'); + logStep('client.billing.savings.ok', savings); + + const spendBreakdown = await apiCall('/client/billing/spend-breakdown?period=month', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(spendBreakdown.items)); + logStep('client.billing.spend-breakdown.ok', { count: spendBreakdown.items.length }); + + const coverage = await apiCall(`/client/coverage?date=${isoDate(0)}`, { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(coverage.items)); + logStep('client.coverage.ok', { count: coverage.items.length }); + + const coverageStats = await apiCall(`/client/coverage/stats?date=${isoDate(0)}`, { + token: ownerSession.sessionToken, + }); + assert.ok(typeof coverageStats.totalCoveragePercentage === 'number'); + logStep('client.coverage.stats.ok', coverageStats); + + const coreTeam = await apiCall('/client/coverage/core-team', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(coreTeam.items)); + logStep('client.coverage.core-team.ok', { count: coreTeam.items.length }); + + const coverageIncidentsBefore = await apiCall(`/client/coverage/incidents?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(coverageIncidentsBefore.items)); + assert.ok(coverageIncidentsBefore.items.length >= 1); + logStep('client.coverage.incidents-before.ok', { count: coverageIncidentsBefore.items.length }); + + const hubs = await apiCall('/client/hubs', { + token: ownerSession.sessionToken, + }); + const seededHub = hubs.items.find((hub) => hub.hubId === fixture.clockPoint.id); + assert.ok(seededHub); + assert.equal(seededHub.clockInMode, fixture.clockPoint.defaultClockInMode); + assert.equal(seededHub.allowClockInOverride, fixture.clockPoint.allowClockInOverride); + logStep('client.hubs.ok', { count: hubs.items.length }); + + const costCenters = await apiCall('/client/cost-centers', { + token: ownerSession.sessionToken, + }); + assert.ok(costCenters.items.length >= 1); + logStep('client.cost-centers.ok', { count: costCenters.items.length }); + + const vendors = await apiCall('/client/vendors', { + token: ownerSession.sessionToken, + }); + assert.ok(vendors.items.length >= 1); + logStep('client.vendors.ok', { count: vendors.items.length }); + + const vendorRoles = await apiCall(`/client/vendors/${fixture.vendor.id}/roles`, { + token: ownerSession.sessionToken, + }); + assert.ok(vendorRoles.items.length >= 1); + logStep('client.vendor-roles.ok', { count: vendorRoles.items.length }); + + const hubManagers = await apiCall(`/client/hubs/${fixture.clockPoint.id}/managers`, { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(hubManagers.items)); + logStep('client.hub-managers.ok', { count: hubManagers.items.length }); + + const teamMembers = await apiCall('/client/team-members', { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(teamMembers.items)); + logStep('client.team-members.ok', { count: teamMembers.items.length }); + + const viewedOrders = await apiCall(`/client/orders/view?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + assert.ok(Array.isArray(viewedOrders.items)); + logStep('client.orders.view.ok', { count: viewedOrders.items.length }); + + const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, { + token: ownerSession.sessionToken, + }); + assert.equal(reorderPreview.orderId, fixture.orders.completed.id); + logStep('client.orders.reorder-preview.ok', reorderPreview); + + const reportSummary = await apiCall(`/client/reports/summary?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + assert.ok(typeof reportSummary.totalShifts === 'number'); + logStep('client.reports.summary.ok', reportSummary); + + const dailyOps = await apiCall(`/client/reports/daily-ops?date=${isoDate(0)}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.daily-ops.ok', dailyOps); + + const spendReport = await apiCall(`/client/reports/spend?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.spend.ok', spendReport); + + const coverageReport = await apiCall(`/client/reports/coverage?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.coverage.ok', coverageReport); + + const forecastReport = await apiCall(`/client/reports/forecast?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.forecast.ok', forecastReport); + + const performanceReport = await apiCall(`/client/reports/performance?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.performance.ok', performanceReport); + + const noShowReport = await apiCall(`/client/reports/no-show?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + logStep('client.reports.no-show.ok', noShowReport); + + const createdHub = await apiCall('/client/hubs', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('create-hub'), + body: { + name: `Smoke Hub ${Date.now()}`, + fullAddress: '500 Castro Street, Mountain View, CA', + latitude: 37.3925, + longitude: -122.0782, + city: 'Mountain View', + state: 'CA', + country: 'US', + zipCode: '94041', + costCenterId: fixture.costCenters.cafeOps.id, + geofenceRadiusMeters: 100, + }, + }); + assert.ok(createdHub.hubId); + logStep('client.hubs.create.ok', createdHub); + + const updatedHub = await apiCall(`/client/hubs/${createdHub.hubId}`, { + method: 'PUT', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('update-hub'), + body: { + name: `${createdHub.name || 'Smoke Hub'} Updated`, + geofenceRadiusMeters: 140, + }, + }); + logStep('client.hubs.update.ok', updatedHub); + + const assignedHubManager = await apiCall(`/client/hubs/${createdHub.hubId}/managers`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('assign-hub-manager'), + body: { + managerUserId: fixture.users.operationsManager.id, + }, + }); + logStep('client.hubs.assign-manager.ok', assignedHubManager); + + const assignedNfc = await apiCall(`/client/hubs/${createdHub.hubId}/assign-nfc`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('assign-hub-nfc'), + body: { + nfcTagId: `NFC-SMOKE-${Date.now()}`, + }, + }); + logStep('client.hubs.assign-nfc.ok', assignedNfc); + + const deletedHub = await apiCall(`/client/hubs/${createdHub.hubId}`, { + method: 'DELETE', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('delete-hub'), + body: { + reason: 'smoke cleanup', + }, + }); + logStep('client.hubs.delete.ok', deletedHub); + + const disputedInvoice = await apiCall(`/client/billing/invoices/${invoiceId}/dispute`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('invoice-dispute'), + body: { + reason: 'Smoke dispute before approval', + }, + }); + logStep('client.billing.invoice-dispute.ok', disputedInvoice); + + const approvedInvoice = await apiCall(`/client/billing/invoices/${invoiceId}/approve`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('invoice-approve'), + body: {}, + }); + logStep('client.billing.invoice-approve.ok', approvedInvoice); + + const createdOneTimeOrder = await apiCall('/client/orders/one-time', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('order-one-time'), + body: { + hubId: fixture.clockPoint.id, + vendorId: fixture.vendor.id, + eventName: `Smoke One-Time ${Date.now()}`, + orderDate: isoDate(3), + serviceType: 'RESTAURANT', + positions: [ + { + roleId: fixture.roles.barista.id, + startTime: '08:00', + endTime: '16:00', + workerCount: 1, + payRateCents: 2200, + billRateCents: 3500, + }, + ], + }, + }); + assert.ok(createdOneTimeOrder.orderId); + logStep('client.orders.create-one-time.ok', createdOneTimeOrder); + + const createdRecurringOrder = await apiCall('/client/orders/recurring', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('order-recurring'), + body: { + hubId: fixture.clockPoint.id, + vendorId: fixture.vendor.id, + eventName: `Smoke Recurring ${Date.now()}`, + startDate: isoDate(5), + endDate: isoDate(10), + recurrenceDays: [1, 3, 5], + serviceType: 'RESTAURANT', + positions: [ + { + roleId: fixture.roles.barista.id, + startTime: '09:00', + endTime: '15:00', + workersNeeded: 1, + payRateCents: 2300, + billRateCents: 3600, + }, + ], + }, + }); + assert.ok(createdRecurringOrder.orderId); + logStep('client.orders.create-recurring.ok', createdRecurringOrder); + + const createdPermanentOrder = await apiCall('/client/orders/permanent', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('order-permanent'), + body: { + hubId: fixture.clockPoint.id, + vendorId: fixture.vendor.id, + eventName: `Smoke Permanent ${Date.now()}`, + startDate: isoDate(7), + daysOfWeek: [1, 2, 3, 4, 5], + horizonDays: 14, + serviceType: 'RESTAURANT', + positions: [ + { + roleId: fixture.roles.barista.id, + startTime: '07:00', + endTime: '13:00', + workersNeeded: 1, + payRateCents: 2200, + billRateCents: 3500, + }, + ], + }, + }); + assert.ok(createdPermanentOrder.orderId); + logStep('client.orders.create-permanent.ok', createdPermanentOrder); + + const editedOrderCopy = await apiCall(`/client/orders/${fixture.orders.completed.id}/edit`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('order-edit'), + body: { + eventName: `Edited Copy ${Date.now()}`, + }, + }); + assert.ok(editedOrderCopy.orderId); + logStep('client.orders.edit-copy.ok', editedOrderCopy); + + const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('order-cancel'), + body: { + reason: 'Smoke cancel validation', + }, + }); + logStep('client.orders.cancel.ok', cancelledOrder); + + const coverageReview = await apiCall('/client/coverage/reviews', { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('coverage-review'), + body: { + staffId: fixture.staff.ana.id, + assignmentId: fixture.assignments.completedAna.id, + rating: 5, + markAsFavorite: true, + issueFlags: [], + feedback: 'Smoke review', + }, + }); + logStep('client.coverage.review.ok', coverageReview); + + const staffSession = await apiCall('/staff/session', { + token: staffAuth.idToken, + }); + assert.equal(staffSession.staff.staffId, fixture.staff.ana.id); + logStep('staff.session.ok', staffSession); + + const staffPushTokenPrimary = await apiCall('/staff/devices/push-tokens', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-push-token-primary'), + body: { + provider: 'FCM', + platform: 'IOS', + pushToken: `smoke-staff-primary-${Date.now()}-abcdefghijklmnop`, + deviceId: 'smoke-staff-iphone-15-pro', + appVersion: '2.0.0-smoke', + appBuild: '2000', + locale: 'en-US', + timezone: 'America/Los_Angeles', + }, + }); + assert.ok(staffPushTokenPrimary.tokenId); + logStep('staff.push-token.register-primary.ok', staffPushTokenPrimary); + + const staffPushTokenCleanup = await apiCall('/staff/devices/push-tokens', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-push-token-cleanup'), + body: { + provider: 'FCM', + platform: 'ANDROID', + pushToken: `smoke-staff-cleanup-${Date.now()}-abcdefghijklmnop`, + deviceId: 'smoke-staff-pixel-9', + appVersion: '2.0.0-smoke', + appBuild: '2001', + locale: 'en-US', + timezone: 'America/Los_Angeles', + }, + }); + assert.ok(staffPushTokenCleanup.tokenId); + logStep('staff.push-token.register-cleanup.ok', staffPushTokenCleanup); + + const staffPushTokenDeleted = await apiCall(`/staff/devices/push-tokens?tokenId=${encodeURIComponent(staffPushTokenCleanup.tokenId)}&reason=SMOKE_CLEANUP`, { + method: 'DELETE', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-push-token-delete'), + }); + assert.equal(staffPushTokenDeleted.removedCount, 1); + logStep('staff.push-token.delete.ok', staffPushTokenDeleted); + + const staffDashboard = await apiCall('/staff/dashboard', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(staffDashboard.recommendedShifts)); + logStep('staff.dashboard.ok', { + todaysShifts: staffDashboard.todaysShifts.length, + recommendedShifts: staffDashboard.recommendedShifts.length, + }); + + const staffProfileCompletion = await apiCall('/staff/profile-completion', { + token: staffAuth.idToken, + }); + logStep('staff.profile-completion.ok', staffProfileCompletion); + + const staffAvailability = await apiCall('/staff/availability', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(staffAvailability.items)); + logStep('staff.availability.ok', { count: staffAvailability.items.length }); + + const todaysShifts = await apiCall('/staff/clock-in/shifts/today', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(todaysShifts.items)); + const assignedTodayShift = todaysShifts.items.find((shift) => shift.shiftId === fixture.shifts.assigned.id); + assert.ok(assignedTodayShift); + assert.equal(assignedTodayShift.clockInMode, fixture.shifts.assigned.clockInMode); + assert.equal(assignedTodayShift.allowClockInOverride, fixture.shifts.assigned.allowClockInOverride); + logStep('staff.clock-in.shifts-today.ok', { count: todaysShifts.items.length }); + + const attendanceStatusBefore = await apiCall('/staff/clock-in/status', { + token: staffAuth.idToken, + }); + logStep('staff.clock-in.status-before.ok', attendanceStatusBefore); + + const paymentsSummary = await apiCall('/staff/payments/summary?period=month', { + token: staffAuth.idToken, + }); + logStep('staff.payments.summary.ok', paymentsSummary); + + const paymentsHistory = await apiCall('/staff/payments/history?period=month', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(paymentsHistory.items)); + logStep('staff.payments.history.ok', { count: paymentsHistory.items.length }); + + const paymentsChart = await apiCall('/staff/payments/chart?period=month', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(paymentsChart.items)); + logStep('staff.payments.chart.ok', { count: paymentsChart.items.length }); + + const assignedShifts = await apiCall(`/staff/shifts/assigned?${reportWindow}`, { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(assignedShifts.items)); + logStep('staff.shifts.assigned.ok', { count: assignedShifts.items.length }); + + const openShifts = await apiCall('/staff/shifts/open', { + token: staffAuth.idToken, + }); + const openShift = openShifts.items.find((shift) => shift.shiftId === fixture.shifts.available.id) + || openShifts.items[0]; + assert.ok(openShift); + logStep('staff.shifts.open.ok', { count: openShifts.items.length }); + + const pendingShifts = await apiCall('/staff/shifts/pending', { + token: staffAuth.idToken, + }); + const pendingShift = pendingShifts.items.find((item) => item.shiftId === fixture.shifts.available.id) + || pendingShifts.items[0]; + assert.ok(pendingShift); + logStep('staff.shifts.pending.ok', { count: pendingShifts.items.length }); + + const cancelledShifts = await apiCall('/staff/shifts/cancelled', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(cancelledShifts.items)); + logStep('staff.shifts.cancelled.ok', { count: cancelledShifts.items.length }); + + const completedShifts = await apiCall('/staff/shifts/completed', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(completedShifts.items)); + logStep('staff.shifts.completed.ok', { count: completedShifts.items.length }); + + const shiftDetail = await apiCall(`/staff/shifts/${openShift.shiftId}`, { + token: staffAuth.idToken, + }); + assert.equal(shiftDetail.shiftId, openShift.shiftId); + logStep('staff.shifts.detail.ok', shiftDetail); + + const profileSections = await apiCall('/staff/profile/sections', { + token: staffAuth.idToken, + }); + logStep('staff.profile.sections.ok', profileSections); + + const personalInfo = await apiCall('/staff/profile/personal-info', { + token: staffAuth.idToken, + }); + logStep('staff.profile.personal-info.ok', personalInfo); + + const industries = await apiCall('/staff/profile/industries', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(industries.items)); + logStep('staff.profile.industries.ok', industries); + + const skills = await apiCall('/staff/profile/skills', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(skills.items)); + logStep('staff.profile.skills.ok', skills); + + const profileDocumentsBefore = await apiCall('/staff/profile/documents', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(profileDocumentsBefore.items)); + logStep('staff.profile.documents-before.ok', { count: profileDocumentsBefore.items.length }); + + const attireChecklistBefore = await apiCall('/staff/profile/attire', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(attireChecklistBefore.items)); + logStep('staff.profile.attire-before.ok', { count: attireChecklistBefore.items.length }); + + const taxForms = await apiCall('/staff/profile/tax-forms', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(taxForms.items)); + logStep('staff.profile.tax-forms.ok', { count: taxForms.items.length }); + + const emergencyContactsBefore = await apiCall('/staff/profile/emergency-contacts', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(emergencyContactsBefore.items)); + logStep('staff.profile.emergency-contacts-before.ok', { count: emergencyContactsBefore.items.length }); + + const certificatesBefore = await apiCall('/staff/profile/certificates', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(certificatesBefore.items)); + logStep('staff.profile.certificates-before.ok', { count: certificatesBefore.items.length }); + + const bankAccountsBefore = await apiCall('/staff/profile/bank-accounts', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(bankAccountsBefore.items)); + logStep('staff.profile.bank-accounts-before.ok', { count: bankAccountsBefore.items.length }); + + const benefits = await apiCall('/staff/profile/benefits', { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(benefits.items)); + logStep('staff.profile.benefits.ok', { count: benefits.items.length }); + + const timeCard = await apiCall(`/staff/profile/time-card?month=${new Date().getUTCMonth() + 1}&year=${new Date().getUTCFullYear()}`, { + token: staffAuth.idToken, + }); + assert.ok(Array.isArray(timeCard.items)); + logStep('staff.profile.time-card.ok', { count: timeCard.items.length }); + + const privacyBefore = await apiCall('/staff/profile/privacy', { + token: staffAuth.idToken, + }); + logStep('staff.profile.privacy-before.ok', privacyBefore); + + const faqs = await apiCall('/staff/faqs'); + assert.ok(Array.isArray(faqs.items)); + logStep('staff.faqs.ok', { count: faqs.items.length }); + + const faqSearch = await apiCall('/staff/faqs/search?q=payments'); + assert.ok(Array.isArray(faqSearch.items)); + logStep('staff.faqs.search.ok', { count: faqSearch.items.length }); + + const updatedAvailability = await apiCall('/staff/availability', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-availability'), + body: { + dayOfWeek: 2, + availabilityStatus: 'PARTIAL', + slots: [{ start: '10:00', end: '18:00' }], + }, + }); + logStep('staff.availability.update.ok', updatedAvailability); + + const quickSetAvailability = await apiCall('/staff/availability/quick-set', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-availability-quick-set'), + body: { + quickSetType: 'weekdays', + startDate: isoTimestamp(0), + endDate: isoTimestamp(24 * 7), + }, + }); + logStep('staff.availability.quick-set.ok', quickSetAvailability); + + const personalInfoUpdate = await apiCall('/staff/profile/personal-info', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-personal-info'), + body: { + firstName: 'Ana', + lastName: 'Barista', + bio: 'Smoke-tested staff bio', + preferredLocations: [ + { + label: 'Mountain View', + city: 'Mountain View', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + radiusMiles: 20, + }, + ], + phone: fixture.staff.ana.phone, + email: staffEmail, + }, + }); + logStep('staff.profile.personal-info.update.ok', personalInfoUpdate); + + const experienceUpdate = await apiCall('/staff/profile/experience', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-experience'), + body: { + industries: ['CATERING', 'CAFE'], + skills: ['BARISTA', 'CUSTOMER_SERVICE'], + primaryRole: 'BARISTA', + }, + }); + logStep('staff.profile.experience.update.ok', experienceUpdate); + + const locationUpdate = await apiCall('/staff/profile/locations', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-locations'), + body: { + preferredLocations: [ + { + label: 'Mountain View', + city: 'Mountain View', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + radiusMiles: 25, + }, + ], + maxDistanceMiles: 25, + }, + }); + logStep('staff.profile.locations.update.ok', locationUpdate); + + const createdEmergencyContact = await apiCall('/staff/profile/emergency-contacts', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-emergency-create'), + body: { + fullName: 'Smoke Contact', + phone: '+15550009999', + relationshipType: 'Sibling', + isPrimary: false, + }, + }); + assert.ok(createdEmergencyContact.contactId); + logStep('staff.profile.emergency-contact.create.ok', createdEmergencyContact); + + const updatedEmergencyContact = await apiCall(`/staff/profile/emergency-contacts/${createdEmergencyContact.contactId}`, { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-emergency-update'), + body: { + fullName: 'Smoke Contact Updated', + relationshipType: 'Brother', + }, + }); + logStep('staff.profile.emergency-contact.update.ok', updatedEmergencyContact); + + const savedW4Draft = await apiCall('/staff/profile/tax-forms/w4', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-tax-w4-draft'), + body: { + fields: { + filingStatus: 'single', + }, + }, + }); + logStep('staff.profile.tax-form.w4-draft.ok', savedW4Draft); + + const submittedI9 = await apiCall('/staff/profile/tax-forms/i9/submit', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-tax-i9-submit'), + body: { + fields: { + section1Complete: true, + }, + }, + }); + logStep('staff.profile.tax-form.i9-submit.ok', submittedI9); + + const addedBankAccount = await apiCall('/staff/profile/bank-accounts', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-bank-account'), + body: { + bankName: 'Demo Credit Union', + accountNumber: '1234567890', + routingNumber: '021000021', + accountType: 'checking', + }, + }); + logStep('staff.profile.bank-account.add.ok', addedBankAccount); + + const updatedPrivacy = await apiCall('/staff/profile/privacy', { + method: 'PUT', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-privacy'), + body: { + profileVisible: true, + }, + }); + logStep('staff.profile.privacy.update.ok', updatedPrivacy); + + const appliedShift = await apiCall(`/staff/shifts/${openShift.shiftId}/apply`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-shift-apply'), + body: { + roleId: fixture.shiftRoles.availableBarista.id, + }, + }); + logStep('staff.shifts.apply.ok', appliedShift); + + const acceptedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/accept`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-shift-accept'), + body: {}, + }); + logStep('staff.shifts.accept.ok', acceptedShift); + + const clockIn = await apiCall('/staff/clock-in', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-clock-in'), + body: { + shiftId: fixture.shifts.assigned.id, + sourceType: 'GEO', + deviceId: 'smoke-iphone-15-pro', + latitude: fixture.clockPoint.latitude + 0.0075, + longitude: fixture.clockPoint.longitude + 0.0075, + accuracyMeters: 8, + proofNonce: uniqueKey('geo-proof-clock-in'), + proofTimestamp: isoTimestamp(0), + overrideReason: 'Parking garage entrance is outside the marked hub geofence', + capturedAt: isoTimestamp(0), + }, + }); + assert.equal(clockIn.validationStatus, 'FLAGGED'); + assert.equal(clockIn.effectiveClockInMode, fixture.shifts.assigned.clockInMode); + assert.equal(clockIn.overrideUsed, true); + assert.ok(clockIn.securityProofId); + logStep('staff.clock-in.ok', clockIn); + + const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', { + token: staffAuth.idToken, + }); + logStep('staff.clock-in.status-after.ok', attendanceStatusAfterClockIn); + + const locationStreamBatch = await apiCall('/staff/location-streams', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-location-stream'), + body: { + shiftId: fixture.shifts.assigned.id, + sourceType: 'GEO', + deviceId: 'smoke-iphone-15-pro', + points: [ + { + capturedAt: isoTimestamp(0.05), + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + accuracyMeters: 12, + }, + { + capturedAt: isoTimestamp(0.1), + latitude: fixture.clockPoint.latitude + 0.008, + longitude: fixture.clockPoint.longitude + 0.008, + accuracyMeters: 20, + }, + { + capturedAt: isoTimestamp(0.15), + accuracyMeters: 25, + }, + ], + metadata: { + source: 'live-smoke-v2-unified', + }, + }, + }); + assert.ok(locationStreamBatch.batchId); + assert.ok(locationStreamBatch.incidentIds.length >= 1); + logStep('staff.location-streams.ok', locationStreamBatch); + + const coverageIncidentsAfter = await apiCall(`/client/coverage/incidents?${reportWindow}`, { + token: ownerSession.sessionToken, + }); + assert.ok(coverageIncidentsAfter.items.length > coverageIncidentsBefore.items.length); + logStep('client.coverage.incidents-after.ok', { count: coverageIncidentsAfter.items.length }); + + const cancelledLateWorker = await apiCall(`/client/coverage/late-workers/${fixture.assignments.noShowAna.id}/cancel`, { + method: 'POST', + token: ownerSession.sessionToken, + idempotencyKey: uniqueKey('client-late-worker-cancel'), + body: { + reason: 'Smoke cancellation for a confirmed late worker', + }, + }); + assert.equal(cancelledLateWorker.assignmentId, fixture.assignments.noShowAna.id); + assert.equal(cancelledLateWorker.status, 'CANCELLED'); + assert.equal(cancelledLateWorker.replacementSearchTriggered, true); + logStep('client.coverage.late-worker-cancel.ok', cancelledLateWorker); + + const clockOut = await apiCall('/staff/clock-out', { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-clock-out'), + body: { + shiftId: fixture.shifts.assigned.id, + sourceType: 'GEO', + deviceId: 'smoke-iphone-15-pro', + latitude: fixture.clockPoint.latitude, + longitude: fixture.clockPoint.longitude, + accuracyMeters: 10, + proofNonce: uniqueKey('geo-proof-clock-out'), + proofTimestamp: isoTimestamp(1), + breakMinutes: 30, + capturedAt: isoTimestamp(1), + }, + }); + assert.ok(clockOut.securityProofId); + logStep('staff.clock-out.ok', clockOut); + + const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, { + method: 'POST', + token: staffAuth.idToken, + idempotencyKey: uniqueKey('staff-shift-swap'), + body: { + reason: 'Smoke swap request', + }, + }); + logStep('staff.shifts.request-swap.ok', requestedSwap); + + const uploadedProfilePhoto = await uploadFile('/staff/profile/photo', staffAuth.idToken, { + filename: 'profile-photo.jpg', + contentType: 'image/jpeg', + content: Buffer.from('fake-profile-photo'), + }); + assert.ok(uploadedProfilePhoto.fileUri); + logStep('staff.profile.photo.upload.ok', uploadedProfilePhoto); + + const uploadedGovId = await uploadFile(`/staff/profile/documents/${fixture.documents.governmentId.id}/upload`, staffAuth.idToken, { + filename: 'government-id.jpg', + contentType: 'image/jpeg', + content: Buffer.from('fake-government-id'), + }); + assert.equal(uploadedGovId.documentId, fixture.documents.governmentId.id); + logStep('staff.profile.document.upload.ok', uploadedGovId); + + const uploadedAttire = await uploadFile(`/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`, staffAuth.idToken, { + filename: 'black-shirt.jpg', + contentType: 'image/jpeg', + content: Buffer.from('fake-black-shirt'), + }); + assert.equal(uploadedAttire.documentId, fixture.documents.attireBlackShirt.id); + logStep('staff.profile.attire.upload.ok', uploadedAttire); + + const certificateType = `ALCOHOL_SERVICE_${Date.now()}`; + const uploadedCertificate = await uploadFile('/staff/profile/certificates', staffAuth.idToken, { + filename: 'certificate.pdf', + contentType: 'application/pdf', + content: Buffer.from('fake-certificate'), + fields: { + certificateType, + name: 'Alcohol Service Permit', + issuer: 'Demo Issuer', + }, + }); + assert.equal(uploadedCertificate.certificateType, certificateType); + logStep('staff.profile.certificate.upload.ok', uploadedCertificate); + + const profileDocumentsAfter = await apiCall('/staff/profile/documents', { + token: staffAuth.idToken, + }); + assert.ok(profileDocumentsAfter.items.some((item) => item.documentId === fixture.documents.governmentId.id)); + logStep('staff.profile.documents-after.ok', { count: profileDocumentsAfter.items.length }); + + const certificatesAfter = await apiCall('/staff/profile/certificates', { + token: staffAuth.idToken, + }); + assert.ok(certificatesAfter.items.some((item) => item.certificateType === certificateType)); + logStep('staff.profile.certificates-after.ok', { count: certificatesAfter.items.length }); + + const deletedCertificate = await apiCall(`/staff/profile/certificates/${certificateType}`, { + method: 'DELETE', + token: staffAuth.idToken, + expectedStatus: 200, + }); + logStep('staff.profile.certificate.delete.ok', deletedCertificate); + + const clientSignOut = await apiCall('/auth/client/sign-out', { + method: 'POST', + token: ownerSession.sessionToken, + }); + logStep('auth.client.sign-out.ok', clientSignOut); + + const staffSignOut = await apiCall('/auth/staff/sign-out', { + method: 'POST', + token: staffAuth.idToken, + }); + logStep('auth.staff.sign-out.ok', staffSignOut); + + // eslint-disable-next-line no-console + console.log('LIVE_SMOKE_V2_UNIFIED_OK'); +} + +main().catch((error) => { + // eslint-disable-next-line no-console + console.error(error); + process.exit(1); +}); diff --git a/backend/unified-api/src/app.js b/backend/unified-api/src/app.js new file mode 100644 index 00000000..a30f8657 --- /dev/null +++ b/backend/unified-api/src/app.js @@ -0,0 +1,31 @@ +import express from 'express'; +import pino from 'pino'; +import pinoHttp from 'pino-http'; +import { requestContext } from './middleware/request-context.js'; +import { errorHandler, notFoundHandler } from './middleware/error-handler.js'; +import { healthRouter } from './routes/health.js'; +import { createAuthRouter } from './routes/auth.js'; +import { createProxyRouter } from './routes/proxy.js'; + +const logger = pino({ level: process.env.LOG_LEVEL || 'info' }); + +export function createApp(options = {}) { + const app = express(); + + app.use(requestContext); + app.use( + pinoHttp({ + logger, + customProps: (req) => ({ requestId: req.requestId }), + }) + ); + + app.use(healthRouter); + app.use('/auth', createAuthRouter({ fetchImpl: options.fetchImpl, authService: options.authService })); + app.use(createProxyRouter(options)); + + app.use(notFoundHandler); + app.use(errorHandler); + + return app; +} diff --git a/backend/unified-api/src/lib/errors.js b/backend/unified-api/src/lib/errors.js new file mode 100644 index 00000000..05548b32 --- /dev/null +++ b/backend/unified-api/src/lib/errors.js @@ -0,0 +1,26 @@ +export class AppError extends Error { + constructor(code, message, status = 400, details = {}) { + super(message); + this.name = 'AppError'; + this.code = code; + this.status = status; + this.details = details; + } +} + +export function toErrorEnvelope(error, requestId) { + const status = error?.status && Number.isInteger(error.status) ? error.status : 500; + const code = error?.code || 'INTERNAL_ERROR'; + const message = error?.message || 'Unexpected error'; + const details = error?.details || {}; + + return { + status, + body: { + code, + message, + details, + requestId, + }, + }; +} diff --git a/backend/unified-api/src/middleware/error-handler.js b/backend/unified-api/src/middleware/error-handler.js new file mode 100644 index 00000000..289395f3 --- /dev/null +++ b/backend/unified-api/src/middleware/error-handler.js @@ -0,0 +1,25 @@ +import { toErrorEnvelope } from '../lib/errors.js'; + +export function notFoundHandler(req, res) { + res.status(404).json({ + code: 'NOT_FOUND', + message: `Route not found: ${req.method} ${req.path}`, + details: {}, + requestId: req.requestId, + }); +} + +export function errorHandler(error, req, res, _next) { + const envelope = toErrorEnvelope(error, req.requestId); + if (req.log) { + req.log.error( + { + errCode: envelope.body.code, + status: envelope.status, + details: envelope.body.details, + }, + envelope.body.message + ); + } + res.status(envelope.status).json(envelope.body); +} diff --git a/backend/unified-api/src/middleware/request-context.js b/backend/unified-api/src/middleware/request-context.js new file mode 100644 index 00000000..c633acbb --- /dev/null +++ b/backend/unified-api/src/middleware/request-context.js @@ -0,0 +1,9 @@ +import { randomUUID } from 'node:crypto'; + +export function requestContext(req, res, next) { + const incoming = req.get('X-Request-Id'); + req.requestId = incoming || randomUUID(); + res.setHeader('X-Request-Id', req.requestId); + res.locals.startedAt = Date.now(); + next(); +} diff --git a/backend/unified-api/src/routes/auth.js b/backend/unified-api/src/routes/auth.js new file mode 100644 index 00000000..7d101511 --- /dev/null +++ b/backend/unified-api/src/routes/auth.js @@ -0,0 +1,170 @@ +import express from 'express'; +import { AppError } from '../lib/errors.js'; +import { + getSessionForActor, + parseClientSignIn, + parseClientSignUp, + parseStaffPhoneStart, + parseStaffPhoneVerify, + signInClient, + signOutActor, + signUpClient, + startStaffPhoneAuth, + verifyStaffPhoneAuth, +} from '../services/auth-service.js'; +import { verifyFirebaseToken } from '../services/firebase-auth.js'; + +const defaultAuthService = { + parseClientSignIn, + parseClientSignUp, + parseStaffPhoneStart, + parseStaffPhoneVerify, + signInClient, + signOutActor, + signUpClient, + startStaffPhoneAuth, + verifyStaffPhoneAuth, + getSessionForActor, +}; + +function getBearerToken(header) { + if (!header) return null; + const [scheme, token] = header.split(' '); + if (!scheme || scheme.toLowerCase() !== 'bearer' || !token) return null; + return token; +} + +async function requireAuth(req, _res, next) { + try { + const token = getBearerToken(req.get('Authorization')); + if (!token) { + throw new AppError('UNAUTHENTICATED', 'Missing bearer token', 401); + } + + if (process.env.AUTH_BYPASS === 'true') { + req.actor = { uid: 'test-user', email: 'test@krow.local', role: 'TEST' }; + return next(); + } + + const decoded = await verifyFirebaseToken(token); + req.actor = { + uid: decoded.uid, + email: decoded.email || null, + role: decoded.role || null, + }; + return next(); + } catch (error) { + if (error instanceof AppError) return next(error); + return next(new AppError('UNAUTHENTICATED', 'Token verification failed', 401)); + } +} + +export function createAuthRouter(options = {}) { + const router = express.Router(); + const fetchImpl = options.fetchImpl || fetch; + const authService = options.authService || defaultAuthService; + + router.use(express.json({ limit: '1mb' })); + + router.post('/client/sign-in', async (req, res, next) => { + try { + const payload = authService.parseClientSignIn(req.body); + const session = await authService.signInClient(payload, { fetchImpl }); + return res.status(200).json({ + ...session, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + + router.post('/client/sign-up', async (req, res, next) => { + try { + const payload = authService.parseClientSignUp(req.body); + const session = await authService.signUpClient(payload, { fetchImpl }); + return res.status(201).json({ + ...session, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + + router.post('/staff/phone/start', async (req, res, next) => { + try { + const payload = authService.parseStaffPhoneStart(req.body); + const result = await authService.startStaffPhoneAuth(payload, { fetchImpl }); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + + router.post('/staff/phone/verify', async (req, res, next) => { + try { + const payload = authService.parseStaffPhoneVerify(req.body); + const session = await authService.verifyStaffPhoneAuth(payload, { fetchImpl }); + return res.status(200).json({ + ...session, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + + router.get('/session', requireAuth, async (req, res, next) => { + try { + const session = await authService.getSessionForActor(req.actor); + return res.status(200).json({ + ...session, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + + router.post('/sign-out', requireAuth, async (req, res, next) => { + try { + const result = await authService.signOutActor(req.actor); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + + router.post('/client/sign-out', requireAuth, async (req, res, next) => { + try { + const result = await authService.signOutActor(req.actor); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + + router.post('/staff/sign-out', requireAuth, async (req, res, next) => { + try { + const result = await authService.signOutActor(req.actor); + return res.status(200).json({ + ...result, + requestId: req.requestId, + }); + } catch (error) { + return next(error); + } + }); + + return router; +} diff --git a/backend/unified-api/src/routes/health.js b/backend/unified-api/src/routes/health.js new file mode 100644 index 00000000..7fd3a64b --- /dev/null +++ b/backend/unified-api/src/routes/health.js @@ -0,0 +1,45 @@ +import { Router } from 'express'; +import { checkDatabaseHealth, isDatabaseConfigured } from '../services/db.js'; + +export const healthRouter = Router(); + +function healthHandler(req, res) { + res.status(200).json({ + ok: true, + service: 'krow-api-v2', + version: process.env.SERVICE_VERSION || 'dev', + requestId: req.requestId, + }); +} + +healthRouter.get('/health', healthHandler); +healthRouter.get('/healthz', healthHandler); + +healthRouter.get('/readyz', async (req, res) => { + if (!isDatabaseConfigured()) { + return res.status(503).json({ + ok: false, + service: 'krow-api-v2', + status: 'DATABASE_NOT_CONFIGURED', + requestId: req.requestId, + }); + } + + try { + const ok = await checkDatabaseHealth(); + return res.status(ok ? 200 : 503).json({ + ok, + service: 'krow-api-v2', + status: ok ? 'READY' : 'DATABASE_UNAVAILABLE', + requestId: req.requestId, + }); + } catch (error) { + return res.status(503).json({ + ok: false, + service: 'krow-api-v2', + status: 'DATABASE_UNAVAILABLE', + details: { message: error.message }, + requestId: req.requestId, + }); + } +}); diff --git a/backend/unified-api/src/routes/proxy.js b/backend/unified-api/src/routes/proxy.js new file mode 100644 index 00000000..3dcc971a --- /dev/null +++ b/backend/unified-api/src/routes/proxy.js @@ -0,0 +1,156 @@ +import { Router } from 'express'; +import { AppError } from '../lib/errors.js'; + +const HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'content-length', + 'host', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +const DIRECT_CORE_ALIASES = [ + { methods: new Set(['POST']), pattern: /^\/upload-file$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/create-signed-url$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` }, + { + methods: new Set(['POST']), + pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/, + targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`, + }, + { + methods: new Set(['POST']), + pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/, + targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`, + }, + { + methods: new Set(['POST']), + pattern: /^\/staff\/profile\/certificates$/, + targetPath: () => '/core/staff/certificates/upload', + }, + { + methods: new Set(['DELETE']), + pattern: /^\/staff\/profile\/certificates\/([^/]+)$/, + targetPath: (_pathname, match) => `/core/staff/certificates/${match[1]}`, + }, + { methods: new Set(['POST']), pattern: /^\/staff\/documents\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/staff\/attire\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/staff\/certificates\/upload$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['DELETE']), pattern: /^\/staff\/certificates\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/verifications$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['GET']), pattern: /^\/verifications\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/review$/, targetPath: (pathname) => `/core${pathname}` }, + { methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/retry$/, targetPath: (pathname) => `/core${pathname}` }, +]; + +function resolveTarget(pathname, method) { + const upperMethod = method.toUpperCase(); + + if (pathname.startsWith('/core')) { + return { + baseUrl: process.env.CORE_API_BASE_URL, + upstreamPath: pathname, + }; + } + + if (pathname.startsWith('/commands')) { + return { + baseUrl: process.env.COMMAND_API_BASE_URL, + upstreamPath: pathname, + }; + } + + if (pathname.startsWith('/query')) { + return { + baseUrl: process.env.QUERY_API_BASE_URL, + upstreamPath: pathname, + }; + } + + for (const alias of DIRECT_CORE_ALIASES) { + if (!alias.methods.has(upperMethod)) continue; + const match = pathname.match(alias.pattern); + if (!match) continue; + return { + baseUrl: process.env.CORE_API_BASE_URL, + upstreamPath: alias.targetPath(pathname, match), + }; + } + + if ((upperMethod === 'GET' || upperMethod === 'HEAD') && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) { + return { + baseUrl: process.env.QUERY_API_BASE_URL, + upstreamPath: `/query${pathname}`, + }; + } + + if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(upperMethod) && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) { + return { + baseUrl: process.env.COMMAND_API_BASE_URL, + upstreamPath: `/commands${pathname}`, + }; + } + + return null; +} + +function copyHeaders(source, target) { + for (const [key, value] of source.entries()) { + if (HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue; + target.setHeader(key, value); + } +} + +async function forwardRequest(req, res, next, fetchImpl) { + try { + const requestUrl = new URL(req.originalUrl, 'http://localhost'); + const target = resolveTarget(requestUrl.pathname, req.method); + if (!target?.baseUrl) { + throw new AppError('NOT_FOUND', `No upstream configured for ${requestUrl.pathname}`, 404); + } + + const url = new URL(`${target.upstreamPath}${requestUrl.search}`, target.baseUrl); + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (value == null || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue; + if (Array.isArray(value)) { + for (const item of value) headers.append(key, item); + } else { + headers.set(key, value); + } + } + headers.set('x-request-id', req.requestId); + + const upstream = await fetchImpl(url, { + method: req.method, + headers, + body: req.method === 'GET' || req.method === 'HEAD' ? undefined : req, + duplex: req.method === 'GET' || req.method === 'HEAD' ? undefined : 'half', + }); + + copyHeaders(upstream.headers, res); + res.status(upstream.status); + + const buffer = Buffer.from(await upstream.arrayBuffer()); + return res.send(buffer); + } catch (error) { + return next(error); + } +} + +export function createProxyRouter(options = {}) { + const router = Router(); + const fetchImpl = options.fetchImpl || fetch; + + router.use((req, res, next) => forwardRequest(req, res, next, fetchImpl)); + + return router; +} diff --git a/backend/unified-api/src/server.js b/backend/unified-api/src/server.js new file mode 100644 index 00000000..b14a4e88 --- /dev/null +++ b/backend/unified-api/src/server.js @@ -0,0 +1,9 @@ +import { createApp } from './app.js'; + +const port = Number(process.env.PORT || 8080); +const app = createApp(); + +app.listen(port, () => { + // eslint-disable-next-line no-console + console.log(`krow-api-v2 listening on port ${port}`); +}); diff --git a/backend/unified-api/src/services/auth-service.js b/backend/unified-api/src/services/auth-service.js new file mode 100644 index 00000000..69efe58b --- /dev/null +++ b/backend/unified-api/src/services/auth-service.js @@ -0,0 +1,304 @@ +import { z } from 'zod'; +import { AppError } from '../lib/errors.js'; +import { withTransaction } from './db.js'; +import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js'; +import { + deleteAccount, + sendVerificationCode, + signInWithPassword, + signInWithPhoneNumber, + signUpWithPassword, +} from './identity-toolkit.js'; +import { loadActorContext } from './user-context.js'; + +const clientSignInSchema = z.object({ + email: z.string().email(), + password: z.string().min(8), +}); + +const clientSignUpSchema = z.object({ + companyName: z.string().min(2).max(120), + email: z.string().email(), + password: z.string().min(8), + displayName: z.string().min(2).max(120).optional(), +}); + +const staffPhoneStartSchema = z.object({ + phoneNumber: z.string().min(6).max(40), + recaptchaToken: z.string().min(1).optional(), + iosReceipt: z.string().min(1).optional(), + iosSecret: z.string().min(1).optional(), + captchaResponse: z.string().min(1).optional(), + playIntegrityToken: z.string().min(1).optional(), + safetyNetToken: z.string().min(1).optional(), +}); + +const staffPhoneVerifySchema = z.object({ + mode: z.enum(['sign-in', 'sign-up']).optional(), + idToken: z.string().min(1).optional(), + sessionInfo: z.string().min(1).optional(), + code: z.string().min(1).optional(), +}).superRefine((value, ctx) => { + if (value.idToken) return; + if (value.sessionInfo && value.code) return; + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Provide idToken or sessionInfo and code', + }); +}); + +function slugify(input) { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50); +} + +function buildAuthEnvelope(authPayload, context) { + return { + sessionToken: authPayload.idToken, + refreshToken: authPayload.refreshToken, + expiresInSeconds: Number.parseInt(`${authPayload.expiresIn || 3600}`, 10), + user: { + id: context.user?.userId || authPayload.localId, + email: context.user?.email || null, + displayName: context.user?.displayName || null, + phone: context.user?.phone || null, + }, + tenant: context.tenant, + business: context.business, + vendor: context.vendor, + staff: context.staff, + requiresProfileSetup: !context.staff, + }; +} + +async function upsertUserFromDecodedToken(decoded, fallbackProfile = {}) { + await withTransaction(async (client) => { + await client.query( + ` + INSERT INTO users (id, email, display_name, phone, status, metadata) + VALUES ($1, $2, $3, $4, 'ACTIVE', COALESCE($5::jsonb, '{}'::jsonb)) + ON CONFLICT (id) DO UPDATE + SET email = COALESCE(EXCLUDED.email, users.email), + display_name = COALESCE(EXCLUDED.display_name, users.display_name), + phone = COALESCE(EXCLUDED.phone, users.phone), + metadata = COALESCE(users.metadata, '{}'::jsonb) || COALESCE(EXCLUDED.metadata, '{}'::jsonb), + updated_at = NOW() + `, + [ + decoded.uid, + decoded.email || fallbackProfile.email || null, + decoded.name || fallbackProfile.displayName || fallbackProfile.email || decoded.phone_number || null, + decoded.phone_number || fallbackProfile.phoneNumber || null, + JSON.stringify({ + provider: decoded.firebase?.sign_in_provider || fallbackProfile.provider || null, + }), + ] + ); + }); +} + +async function hydrateAuthContext(authPayload, fallbackProfile = {}) { + const decoded = await verifyFirebaseToken(authPayload.idToken); + await upsertUserFromDecodedToken(decoded, fallbackProfile); + const context = await loadActorContext(decoded.uid); + return buildAuthEnvelope(authPayload, context); +} + +export function parseClientSignIn(body) { + const parsed = clientSignInSchema.safeParse(body || {}); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid client sign-in payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +export function parseClientSignUp(body) { + const parsed = clientSignUpSchema.safeParse(body || {}); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid client sign-up payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +export function parseStaffPhoneStart(body) { + const parsed = staffPhoneStartSchema.safeParse(body || {}); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid staff phone start payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +export function parseStaffPhoneVerify(body) { + const parsed = staffPhoneVerifySchema.safeParse(body || {}); + if (!parsed.success) { + throw new AppError('VALIDATION_ERROR', 'Invalid staff phone verify payload', 400, { + issues: parsed.error.issues, + }); + } + return parsed.data; +} + +export async function getSessionForActor(actor) { + return loadActorContext(actor.uid); +} + +export async function signInClient(payload, { fetchImpl = fetch } = {}) { + const authPayload = await signInWithPassword(payload, fetchImpl); + const decoded = await verifyFirebaseToken(authPayload.idToken); + await upsertUserFromDecodedToken(decoded, payload); + const context = await loadActorContext(decoded.uid); + + if (!context.user || !context.business) { + throw new AppError('FORBIDDEN', 'Authenticated user does not have a client business membership', 403, { + uid: decoded.uid, + email: decoded.email || null, + }); + } + + return buildAuthEnvelope(authPayload, context); +} + +export async function signUpClient(payload, { fetchImpl = fetch } = {}) { + const authPayload = await signUpWithPassword(payload, fetchImpl); + + try { + const decoded = await verifyFirebaseToken(authPayload.idToken); + const defaultDisplayName = payload.displayName || payload.companyName; + const tenantSlug = slugify(payload.companyName); + const businessSlug = tenantSlug; + + await withTransaction(async (client) => { + await client.query( + ` + INSERT INTO users (id, email, display_name, status, metadata) + VALUES ($1, $2, $3, 'ACTIVE', '{}'::jsonb) + ON CONFLICT (id) DO UPDATE + SET email = EXCLUDED.email, + display_name = EXCLUDED.display_name, + updated_at = NOW() + `, + [decoded.uid, payload.email, defaultDisplayName] + ); + + const tenantResult = await client.query( + ` + INSERT INTO tenants (slug, name, status, metadata) + VALUES ($1, $2, 'ACTIVE', '{"source":"unified-api-sign-up"}'::jsonb) + RETURNING id, slug, name + `, + [tenantSlug, payload.companyName] + ); + const tenant = tenantResult.rows[0]; + + const businessResult = await client.query( + ` + INSERT INTO businesses ( + tenant_id, slug, business_name, status, contact_name, contact_email, metadata + ) + VALUES ($1, $2, $3, 'ACTIVE', $4, $5, '{"source":"unified-api-sign-up"}'::jsonb) + RETURNING id, slug, business_name + `, + [tenant.id, businessSlug, payload.companyName, defaultDisplayName, payload.email] + ); + const business = businessResult.rows[0]; + + await client.query( + ` + INSERT INTO tenant_memberships (tenant_id, user_id, membership_status, base_role, metadata) + VALUES ($1, $2, 'ACTIVE', 'admin', '{"source":"sign-up"}'::jsonb) + `, + [tenant.id, decoded.uid] + ); + + await client.query( + ` + INSERT INTO business_memberships (tenant_id, business_id, user_id, membership_status, business_role, metadata) + VALUES ($1, $2, $3, 'ACTIVE', 'owner', '{"source":"sign-up"}'::jsonb) + `, + [tenant.id, business.id, decoded.uid] + ); + }); + + const context = await loadActorContext(decoded.uid); + return buildAuthEnvelope(authPayload, context); + } catch (error) { + await deleteAccount({ idToken: authPayload.idToken }, fetchImpl).catch(() => null); + throw error; + } +} + +function shouldUseClientSdkStaffFlow(payload) { + return !payload.recaptchaToken && !payload.iosReceipt && !payload.captchaResponse && !payload.playIntegrityToken && !payload.safetyNetToken; +} + +export async function startStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) { + if (shouldUseClientSdkStaffFlow(payload)) { + return { + mode: 'CLIENT_FIREBASE_SDK', + provider: 'firebase-phone-auth', + phoneNumber: payload.phoneNumber, + nextStep: 'Complete phone verification in the mobile client, then call /auth/staff/phone/verify with the Firebase idToken.', + }; + } + + const authPayload = await sendVerificationCode( + { + phoneNumber: payload.phoneNumber, + recaptchaToken: payload.recaptchaToken, + iosReceipt: payload.iosReceipt, + iosSecret: payload.iosSecret, + captchaResponse: payload.captchaResponse, + playIntegrityToken: payload.playIntegrityToken, + safetyNetToken: payload.safetyNetToken, + }, + fetchImpl + ); + + return { + mode: 'IDENTITY_TOOLKIT_SMS', + phoneNumber: payload.phoneNumber, + sessionInfo: authPayload.sessionInfo, + }; +} + +export async function verifyStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) { + if (payload.idToken) { + return hydrateAuthContext( + { + idToken: payload.idToken, + refreshToken: null, + expiresIn: 3600, + }, + { + provider: 'firebase-phone-auth', + } + ); + } + + const authPayload = await signInWithPhoneNumber( + { + sessionInfo: payload.sessionInfo, + code: payload.code, + operation: payload.mode === 'sign-up' ? 'SIGN_UP_OR_IN' : undefined, + }, + fetchImpl + ); + + return hydrateAuthContext(authPayload, { + provider: 'firebase-phone-auth', + }); +} + +export async function signOutActor(actor) { + await revokeUserSessions(actor.uid); + return { signedOut: true }; +} diff --git a/backend/unified-api/src/services/db.js b/backend/unified-api/src/services/db.js new file mode 100644 index 00000000..3beaa683 --- /dev/null +++ b/backend/unified-api/src/services/db.js @@ -0,0 +1,87 @@ +import { Pool } from 'pg'; + +let pool; + +function parseIntOrDefault(value, fallback) { + const parsed = Number.parseInt(`${value || fallback}`, 10); + return Number.isFinite(parsed) ? parsed : fallback; +} + +function resolveDatabasePoolConfig() { + if (process.env.DATABASE_URL) { + return { + connectionString: process.env.DATABASE_URL, + max: parseIntOrDefault(process.env.DB_POOL_MAX, 10), + idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000), + }; + } + + const user = process.env.DB_USER; + const password = process.env.DB_PASSWORD; + const database = process.env.DB_NAME; + const host = process.env.DB_HOST || ( + process.env.INSTANCE_CONNECTION_NAME + ? `/cloudsql/${process.env.INSTANCE_CONNECTION_NAME}` + : '' + ); + + if (!user || password == null || !database || !host) { + return null; + } + + return { + host, + port: parseIntOrDefault(process.env.DB_PORT, 5432), + user, + password, + database, + max: parseIntOrDefault(process.env.DB_POOL_MAX, 10), + idleTimeoutMillis: parseIntOrDefault(process.env.DB_IDLE_TIMEOUT_MS, 30000), + }; +} + +export function isDatabaseConfigured() { + return Boolean(resolveDatabasePoolConfig()); +} + +function getPool() { + if (!pool) { + const resolved = resolveDatabasePoolConfig(); + if (!resolved) { + throw new Error('Database connection settings are required'); + } + pool = new Pool(resolved); + } + return pool; +} + +export async function query(text, params = []) { + return getPool().query(text, params); +} + +export async function withTransaction(work) { + const client = await getPool().connect(); + try { + await client.query('BEGIN'); + const result = await work(client); + await client.query('COMMIT'); + return result; + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +} + +export async function checkDatabaseHealth() { + const result = await query('SELECT 1 AS ok'); + return result.rows[0]?.ok === 1; +} + +export async function closePool() { + if (pool) { + await pool.end(); + pool = null; + } +} diff --git a/backend/unified-api/src/services/firebase-auth.js b/backend/unified-api/src/services/firebase-auth.js new file mode 100644 index 00000000..ed2c1839 --- /dev/null +++ b/backend/unified-api/src/services/firebase-auth.js @@ -0,0 +1,23 @@ +import { applicationDefault, getApps, initializeApp } from 'firebase-admin/app'; +import { getAuth } from 'firebase-admin/auth'; + +function ensureAdminApp() { + if (getApps().length === 0) { + initializeApp({ credential: applicationDefault() }); + } +} + +export async function verifyFirebaseToken(token, { checkRevoked = false } = {}) { + ensureAdminApp(); + return getAuth().verifyIdToken(token, checkRevoked); +} + +export async function revokeUserSessions(uid) { + ensureAdminApp(); + await getAuth().revokeRefreshTokens(uid); +} + +export async function createCustomToken(uid) { + ensureAdminApp(); + return getAuth().createCustomToken(uid); +} diff --git a/backend/unified-api/src/services/identity-toolkit.js b/backend/unified-api/src/services/identity-toolkit.js new file mode 100644 index 00000000..f9ed245f --- /dev/null +++ b/backend/unified-api/src/services/identity-toolkit.js @@ -0,0 +1,92 @@ +import { AppError } from '../lib/errors.js'; + +const IDENTITY_TOOLKIT_BASE_URL = 'https://identitytoolkit.googleapis.com/v1'; + +function getApiKey() { + const apiKey = process.env.FIREBASE_WEB_API_KEY; + if (!apiKey) { + throw new AppError('CONFIGURATION_ERROR', 'FIREBASE_WEB_API_KEY is required', 500); + } + return apiKey; +} + +async function callIdentityToolkit(path, payload, fetchImpl = fetch) { + const response = await fetchImpl(`${IDENTITY_TOOLKIT_BASE_URL}/${path}?key=${getApiKey()}`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const json = await response.json().catch(() => ({})); + if (!response.ok) { + throw new AppError( + 'AUTH_PROVIDER_ERROR', + json?.error?.message || `Identity Toolkit request failed: ${path}`, + response.status, + { provider: 'firebase-identity-toolkit', path } + ); + } + + return json; +} + +export async function signInWithPassword({ email, password }, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:signInWithPassword', + { + email, + password, + returnSecureToken: true, + }, + fetchImpl + ); +} + +export async function signUpWithPassword({ email, password }, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:signUp', + { + email, + password, + returnSecureToken: true, + }, + fetchImpl + ); +} + +export async function sendVerificationCode(payload, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:sendVerificationCode', + payload, + fetchImpl + ); +} + +export async function signInWithPhoneNumber(payload, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:signInWithPhoneNumber', + payload, + fetchImpl + ); +} + +export async function signInWithCustomToken(payload, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:signInWithCustomToken', + { + token: payload.token, + returnSecureToken: true, + }, + fetchImpl + ); +} + +export async function deleteAccount({ idToken }, fetchImpl = fetch) { + return callIdentityToolkit( + 'accounts:delete', + { idToken }, + fetchImpl + ); +} diff --git a/backend/unified-api/src/services/user-context.js b/backend/unified-api/src/services/user-context.js new file mode 100644 index 00000000..6262e886 --- /dev/null +++ b/backend/unified-api/src/services/user-context.js @@ -0,0 +1,91 @@ +import { query } from './db.js'; + +export async function loadActorContext(uid) { + const [userResult, tenantResult, businessResult, vendorResult, staffResult] = await Promise.all([ + query( + ` + SELECT id AS "userId", email, display_name AS "displayName", phone, status + FROM users + WHERE id = $1 + `, + [uid] + ), + query( + ` + SELECT tm.id AS "membershipId", + tm.tenant_id AS "tenantId", + tm.base_role AS role, + t.name AS "tenantName", + t.slug AS "tenantSlug" + FROM tenant_memberships tm + JOIN tenants t ON t.id = tm.tenant_id + WHERE tm.user_id = $1 + AND tm.membership_status = 'ACTIVE' + ORDER BY tm.created_at ASC + LIMIT 1 + `, + [uid] + ), + query( + ` + SELECT bm.id AS "membershipId", + bm.business_id AS "businessId", + bm.business_role AS role, + b.business_name AS "businessName", + b.slug AS "businessSlug", + bm.tenant_id AS "tenantId" + FROM business_memberships bm + JOIN businesses b ON b.id = bm.business_id + WHERE bm.user_id = $1 + AND bm.membership_status = 'ACTIVE' + ORDER BY bm.created_at ASC + LIMIT 1 + `, + [uid] + ), + query( + ` + SELECT vm.id AS "membershipId", + vm.vendor_id AS "vendorId", + vm.vendor_role AS role, + v.company_name AS "vendorName", + v.slug AS "vendorSlug", + vm.tenant_id AS "tenantId" + FROM vendor_memberships vm + JOIN vendors v ON v.id = vm.vendor_id + WHERE vm.user_id = $1 + AND vm.membership_status = 'ACTIVE' + ORDER BY vm.created_at ASC + LIMIT 1 + `, + [uid] + ), + query( + ` + SELECT s.id AS "staffId", + s.tenant_id AS "tenantId", + s.full_name AS "fullName", + s.primary_role AS "primaryRole", + s.onboarding_status AS "onboardingStatus", + s.status, + w.id AS "workforceId", + w.vendor_id AS "vendorId", + w.workforce_number AS "workforceNumber" + FROM staffs s + LEFT JOIN workforce w ON w.staff_id = s.id + WHERE s.user_id = $1 + ORDER BY s.created_at ASC + LIMIT 1 + `, + [uid] + ), + ]); + + return { + user: userResult.rows[0] || null, + tenant: tenantResult.rows[0] || null, + business: businessResult.rows[0] || null, + vendor: vendorResult.rows[0] || null, + staff: staffResult.rows[0] || null, + }; +} diff --git a/backend/unified-api/test/app.test.js b/backend/unified-api/test/app.test.js new file mode 100644 index 00000000..113cfbec --- /dev/null +++ b/backend/unified-api/test/app.test.js @@ -0,0 +1,184 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; + +process.env.AUTH_BYPASS = 'true'; + +test('GET /healthz returns healthy response', async () => { + const app = createApp(); + const res = await request(app).get('/healthz'); + + assert.equal(res.status, 200); + assert.equal(res.body.ok, true); + assert.equal(res.body.service, 'krow-api-v2'); +}); + +test('GET /readyz reports database not configured when env is absent', async () => { + delete process.env.DATABASE_URL; + delete process.env.DB_HOST; + delete process.env.DB_NAME; + delete process.env.DB_USER; + delete process.env.DB_PASSWORD; + delete process.env.INSTANCE_CONNECTION_NAME; + + const app = createApp(); + const res = await request(app).get('/readyz'); + + assert.equal(res.status, 503); + assert.equal(res.body.status, 'DATABASE_NOT_CONFIGURED'); +}); + +test('POST /auth/client/sign-in validates payload', async () => { + const app = createApp(); + const res = await request(app).post('/auth/client/sign-in').send({ + email: 'bad-email', + password: 'short', + }); + + assert.equal(res.status, 400); + assert.equal(res.body.code, 'VALIDATION_ERROR'); +}); + +test('POST /auth/client/sign-in returns injected auth envelope', async () => { + const app = createApp({ + authService: { + parseClientSignIn: (body) => body, + parseClientSignUp: (body) => body, + signInClient: async () => ({ + sessionToken: 'token', + refreshToken: 'refresh', + expiresInSeconds: 3600, + user: { id: 'u1', email: 'legendary@krowd.com' }, + tenant: { tenantId: 't1' }, + business: { businessId: 'b1' }, + }), + signUpClient: async () => assert.fail('signUpClient should not be called'), + signOutActor: async () => ({ signedOut: true }), + getSessionForActor: async () => ({ user: { userId: 'u1' } }), + }, + }); + + const res = await request(app).post('/auth/client/sign-in').send({ + email: 'legendary@krowd.com', + password: 'super-secret', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.sessionToken, 'token'); + assert.equal(res.body.business.businessId, 'b1'); +}); + +test('GET /auth/session returns injected session for authenticated actor', async () => { + const app = createApp({ + authService: { + parseClientSignIn: (body) => body, + parseClientSignUp: (body) => body, + signInClient: async () => assert.fail('signInClient should not be called'), + signUpClient: async () => assert.fail('signUpClient should not be called'), + signOutActor: async () => ({ signedOut: true }), + getSessionForActor: async (actor) => ({ actorUid: actor.uid }), + }, + }); + + const res = await request(app) + .get('/auth/session') + .set('Authorization', 'Bearer test-token'); + + assert.equal(res.status, 200); + assert.equal(res.body.actorUid, 'test-user'); +}); + +test('proxy forwards query routes to query base url', async () => { + process.env.QUERY_API_BASE_URL = 'https://query.example'; + process.env.CORE_API_BASE_URL = 'https://core.example'; + process.env.COMMAND_API_BASE_URL = 'https://command.example'; + + let seenUrl = null; + const app = createApp({ + fetchImpl: async (url) => { + seenUrl = `${url}`; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + const res = await request(app).get('/query/test-route?foo=bar'); + + assert.equal(res.status, 200); + assert.equal(seenUrl, 'https://query.example/query/test-route?foo=bar'); +}); + +test('proxy forwards direct client read routes to query api', async () => { + process.env.QUERY_API_BASE_URL = 'https://query.example'; + process.env.CORE_API_BASE_URL = 'https://core.example'; + process.env.COMMAND_API_BASE_URL = 'https://command.example'; + + let seenUrl = null; + const app = createApp({ + fetchImpl: async (url) => { + seenUrl = `${url}`; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + const res = await request(app).get('/client/dashboard'); + + assert.equal(res.status, 200); + assert.equal(seenUrl, 'https://query.example/query/client/dashboard'); +}); + +test('proxy forwards direct client write routes to command api', async () => { + process.env.QUERY_API_BASE_URL = 'https://query.example'; + process.env.CORE_API_BASE_URL = 'https://core.example'; + process.env.COMMAND_API_BASE_URL = 'https://command.example'; + + let seenUrl = null; + const app = createApp({ + fetchImpl: async (url) => { + seenUrl = `${url}`; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + const res = await request(app) + .post('/client/orders/one-time') + .set('Authorization', 'Bearer test-token') + .send({ ok: true }); + + assert.equal(res.status, 200); + assert.equal(seenUrl, 'https://command.example/commands/client/orders/one-time'); +}); + +test('proxy forwards direct core upload aliases to core api', async () => { + process.env.QUERY_API_BASE_URL = 'https://query.example'; + process.env.CORE_API_BASE_URL = 'https://core.example'; + process.env.COMMAND_API_BASE_URL = 'https://command.example'; + + let seenUrl = null; + const app = createApp({ + fetchImpl: async (url) => { + seenUrl = `${url}`; + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }, + }); + + const res = await request(app) + .post('/staff/profile/certificates') + .set('Authorization', 'Bearer test-token') + .send({ ok: true }); + + assert.equal(res.status, 200); + assert.equal(seenUrl, 'https://core.example/core/staff/certificates/upload'); +}); diff --git a/backend/unified-api/test/staff-auth.test.js b/backend/unified-api/test/staff-auth.test.js new file mode 100644 index 00000000..37c8aa42 --- /dev/null +++ b/backend/unified-api/test/staff-auth.test.js @@ -0,0 +1,61 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import request from 'supertest'; +import { createApp } from '../src/app.js'; + +process.env.AUTH_BYPASS = 'true'; + +function createAuthService() { + return { + parseClientSignIn: (body) => body, + parseClientSignUp: (body) => body, + parseStaffPhoneStart: (body) => body, + parseStaffPhoneVerify: (body) => body, + signInClient: async () => assert.fail('signInClient should not be called'), + signUpClient: async () => assert.fail('signUpClient should not be called'), + signOutActor: async () => ({ signedOut: true }), + getSessionForActor: async () => ({ user: { userId: 'u1' } }), + startStaffPhoneAuth: async (payload) => ({ + mode: 'CLIENT_FIREBASE_SDK', + phoneNumber: payload.phoneNumber, + nextStep: 'continue in app', + }), + verifyStaffPhoneAuth: async (payload) => ({ + sessionToken: payload.idToken || 'token', + refreshToken: 'refresh', + expiresInSeconds: 3600, + user: { id: 'staff-user' }, + tenant: { tenantId: 'tenant-1' }, + vendor: { vendorId: 'vendor-1' }, + staff: { staffId: 'staff-1' }, + requiresProfileSetup: false, + }), + }; +} + +test('POST /auth/staff/phone/start returns injected start payload', async () => { + const app = createApp({ authService: createAuthService() }); + const res = await request(app) + .post('/auth/staff/phone/start') + .send({ + phoneNumber: '+15555550123', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.mode, 'CLIENT_FIREBASE_SDK'); + assert.equal(res.body.phoneNumber, '+15555550123'); +}); + +test('POST /auth/staff/phone/verify returns injected auth envelope', async () => { + const app = createApp({ authService: createAuthService() }); + const res = await request(app) + .post('/auth/staff/phone/verify') + .send({ + idToken: 'firebase-id-token', + }); + + assert.equal(res.status, 200); + assert.equal(res.body.sessionToken, 'firebase-id-token'); + assert.equal(res.body.staff.staffId, 'staff-1'); + assert.equal(res.body.requiresProfileSetup, false); +}); diff --git a/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md b/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md index b3f860ab..0ef98e5e 100644 --- a/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md +++ b/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md @@ -1,5 +1,9 @@ # KROW Workforce API Contracts +Legacy note: +Use `/Users/wiel/Development/krow-workforce/docs/BACKEND/API_GUIDES/V2/README.md` for the current v2 frontend/backend integration surface. +This document reflects the earlier Data Connect-oriented contract mapping and should not be the source of truth for new v2 client work. + This document captures all API contracts used by the Staff and Client mobile applications. The application backend is powered by **Firebase Data Connect (GraphQL)**, so traditional REST endpoints do not exist natively. For clarity and ease of reading for all engineering team members, the tables below formulate these GraphQL Data Connect queries and mutations into their **Conceptual REST Endpoints** alongside the actual **Data Connect Operation Name**. --- diff --git a/docs/BACKEND/API_GUIDES/README.md b/docs/BACKEND/API_GUIDES/README.md new file mode 100644 index 00000000..15aa7733 --- /dev/null +++ b/docs/BACKEND/API_GUIDES/README.md @@ -0,0 +1,14 @@ +# Backend API Guides + +## Use this for current frontend work + +- [V2 Backend API Guide](./V2/README.md) +- [V2 Core API](./V2/core-api.md) +- [V2 Command API](./V2/command-api.md) +- [V2 Query API](./V2/query-api.md) + +## Legacy reference + +- [Initial API contracts](./00-initial-api-contracts.md) + +The legacy contract doc reflects the older Data Connect-oriented application shape. Do not use it as the source of truth for new v2 frontend work. diff --git a/docs/BACKEND/API_GUIDES/V2/README.md b/docs/BACKEND/API_GUIDES/V2/README.md new file mode 100644 index 00000000..2025ab4e --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/README.md @@ -0,0 +1,149 @@ +# V2 Backend API Guide + +This is the frontend-facing source of truth for the v2 backend. + +## 1) Use one base URL + +Frontend should call one public gateway: + +```env +API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app +``` + +Frontend should not call the internal `core`, `command`, or `query` Cloud Run services directly. + +## 2) Current status + +The unified v2 gateway is ready for frontend integration in `dev`. + +What was validated live against the deployed stack: + +- client sign-in +- staff auth bootstrap +- client dashboard, billing, coverage, hubs, vendors, managers, team members, orders, and reports +- client coverage incident feed for geofence and override review +- client hub, order, coverage review, device token, and late-worker cancellation flows +- client invoice approve and dispute +- staff dashboard, availability, payments, shifts, profile sections, documents, certificates, attire, bank accounts, benefits, and time card +- staff availability, profile, tax form, bank account, shift apply, shift accept, push token registration, clock-in, clock-out, location stream upload, and swap request +- direct file upload helpers and verification job creation through the unified host +- client and staff sign-out + +The live validation command is: + +```bash +export FIREBASE_WEB_API_KEY="$(gcloud secrets versions access latest --secret=firebase-web-api-key --project=krow-workforce-dev)" +source ~/.nvm/nvm.sh +nvm use 23.5.0 +node backend/unified-api/scripts/live-smoke-v2-unified.mjs +``` + +The demo tenant can be reset with: + +```bash +source ~/.nvm/nvm.sh +nvm use 23.5.0 +cd backend/command-api +npm run seed:v2-demo +``` + +## 3) Auth and headers + +Protected routes require: + +```http +Authorization: Bearer +``` + +Write routes also require: + +```http +Idempotency-Key: +``` + +For now: + +- backend wraps sign-in and sign-out +- frontend can keep using Firebase token refresh on the client +- backend is the only thing frontend should call for session-oriented API flows + +All routes return the same error envelope: + +```json +{ + "code": "STRING_CODE", + "message": "Human readable message", + "details": {}, + "requestId": "uuid" +} +``` + +## 4) Attendance policy and monitoring + +V2 now supports an explicit attendance proof policy: + +- `NFC_REQUIRED` +- `GEO_REQUIRED` +- `EITHER` + +The effective policy is resolved as: + +1. shift override if present +2. hub default if present +3. fallback to `EITHER` + +For geofence-heavy staff flows, frontend should read the policy from: + +- `GET /staff/clock-in/shifts/today` +- `GET /staff/shifts/:shiftId` +- `GET /client/hubs` + +Important operational rules: + +- outside-geofence clock-ins can be accepted only when override is enabled and a written reason is provided +- NFC mismatches are rejected and are not overrideable +- attendance proof logs are durable in SQL and raw object storage +- device push tokens are durable in SQL and can be registered separately for client and staff apps +- background location streams are stored as raw batch payloads in the private v2 bucket and summarized in SQL for query speed +- incident review lives on `GET /client/coverage/incidents` +- confirmed late-worker recovery is exposed on `POST /client/coverage/late-workers/:assignmentId/cancel` +- queued alerts are written to `notification_outbox`, dispatched by the private Cloud Run worker service `krow-notification-worker-v2`, and recorded in `notification_deliveries` + +## 5) Route model + +Frontend sees one base URL and one route shape: + +- `/auth/*` +- `/client/*` +- `/staff/*` +- direct upload aliases like `/upload-file` and `/staff/profile/*` + +Internally, the gateway still forwards to: + +| Frontend use case | Internal service | +| --- | --- | +| auth/session wrapper | `krow-api-v2` | +| uploads, signed URLs, model calls, verification workflows | `core-api-v2` | +| writes and workflow actions | `command-api-v2` | +| reads and mobile read models | `query-api-v2` | + +## 6) Frontend integration rule + +Use the unified routes first. + +Do not build new frontend work on: + +- `/query/tenants/*` +- `/commands/*` +- `/core/*` + +Those routes still exist for backend/internal compatibility, but mobile/frontend migration should target the unified surface documented in [Unified API](./unified-api.md). + +## 7) Docs + +- [Authentication](./authentication.md) +- [Unified API](./unified-api.md) +- [Core API](./core-api.md) +- [Command API](./command-api.md) +- [Query API](./query-api.md) +- [Mobile API Reconciliation](./mobile-api-gap-analysis.md) diff --git a/docs/BACKEND/API_GUIDES/V2/authentication.md b/docs/BACKEND/API_GUIDES/V2/authentication.md new file mode 100644 index 00000000..d90e585a --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/authentication.md @@ -0,0 +1,343 @@ +# V2 Authentication Guide + +This document is the source of truth for V2 authentication. + +Base URL: + +```env +API_V2_BASE_URL=https://krow-api-v2-933560802882.us-central1.run.app +``` + +## 1) What is implemented + +### Client app + +Client authentication is implemented through backend endpoints: + +- `POST /auth/client/sign-in` +- `POST /auth/client/sign-up` +- `POST /auth/client/sign-out` +- `GET /auth/session` + +The backend signs the user in with Firebase Identity Toolkit, validates the user against the V2 database, and returns the full auth envelope. + +### Staff app + +Staff authentication is implemented, but it is a hybrid flow. + +Routes: + +- `POST /auth/staff/phone/start` +- `POST /auth/staff/phone/verify` +- `POST /auth/staff/sign-out` +- `GET /auth/session` + +Important: + +- the default mobile path is **not** a fully backend-managed OTP flow +- the usual mobile path uses the Firebase Auth SDK on-device for phone verification +- after the device gets a Firebase `idToken`, frontend sends that token to `POST /auth/staff/phone/verify` + +So if someone expects `POST /auth/staff/phone/start` to always send the SMS and always return `sessionInfo`, that expectation is wrong for the current implementation + +## 2) Auth refresh + +There is currently **no** backend `/auth/refresh` endpoint. + +That is intentional for now. + +Current refresh model: + +- frontend keeps Firebase Auth local session state +- frontend lets the Firebase SDK refresh the ID token +- frontend sends the latest Firebase ID token in: + +```http +Authorization: Bearer +``` + +Use: + +- `authStateChanges()` / `idTokenChanges()` listeners +- `currentUser.getIdToken()` +- `currentUser.getIdToken(true)` only when a forced refresh is actually needed + +`GET /auth/session` is **not** a refresh endpoint. + +It is a context endpoint used to: + +- hydrate role/tenant/business/staff context +- validate that the signed-in Firebase user is allowed in this app + +## 3) Client auth flow + +### Client sign-in + +Request: + +```http +POST /auth/client/sign-in +Content-Type: application/json +``` + +```json +{ + "email": "legendary.owner+v2@krowd.com", + "password": "Demo2026!" +} +``` + +Response: + +```json +{ + "sessionToken": "firebase-id-token", + "refreshToken": "firebase-refresh-token", + "expiresInSeconds": 3600, + "user": { + "id": "user-uuid", + "email": "legendary.owner+v2@krowd.com", + "displayName": "Legendary Owner", + "phone": null + }, + "tenant": { + "tenantId": "tenant-uuid", + "tenantName": "Legendary Event Staffing and Entertainment" + }, + "business": { + "businessId": "business-uuid", + "businessName": "Google Mountain View Cafes" + }, + "requestId": "uuid" +} +``` + +Frontend behavior: + +1. Call `POST /auth/client/sign-in` +2. If success, sign in locally with Firebase Auth SDK using the same email/password +3. Use Firebase SDK token refresh for later API calls +4. Use `GET /auth/session` when role/session hydration is needed on app boot + +### Client sign-up + +Request: + +```http +POST /auth/client/sign-up +Content-Type: application/json +``` + +```json +{ + "companyName": "Legendary Event Staffing and Entertainment", + "email": "legendary.owner+v2@krowd.com", + "password": "Demo2026!" +} +``` + +What it does: + +- creates Firebase Auth account +- creates V2 user +- creates tenant +- creates business +- creates tenant membership +- creates business membership + +Frontend behavior after success: + +1. call `POST /auth/client/sign-up` +2. sign in locally with Firebase Auth SDK using the same email/password +3. use Firebase SDK for later token refresh + +## 4) Staff auth flow + +## Step 1: start phone auth + +Request: + +```http +POST /auth/staff/phone/start +Content-Type: application/json +``` + +```json +{ + "phoneNumber": "+15551234567" +} +``` + +Possible response A: + +```json +{ + "mode": "CLIENT_FIREBASE_SDK", + "provider": "firebase-phone-auth", + "phoneNumber": "+15551234567", + "nextStep": "Complete phone verification in the mobile client, then call /auth/staff/phone/verify with the Firebase idToken.", + "requestId": "uuid" +} +``` + +This is the normal mobile path when frontend does **not** send recaptcha or integrity tokens. + +Possible response B: + +```json +{ + "mode": "IDENTITY_TOOLKIT_SMS", + "phoneNumber": "+15551234567", + "sessionInfo": "firebase-session-info", + "requestId": "uuid" +} +``` + +This is the server-managed SMS path. + +## Step 2A: normal mobile path (`CLIENT_FIREBASE_SDK`) + +Frontend must do this on-device: + +1. call `FirebaseAuth.verifyPhoneNumber(...)` +2. collect the `verificationId` +3. collect the OTP code from the user +4. create a Firebase phone credential +5. call `signInWithCredential(...)` +6. get Firebase `idToken` +7. call `POST /auth/staff/phone/verify` with that `idToken` + +Request: + +```http +POST /auth/staff/phone/verify +Content-Type: application/json +``` + +```json +{ + "mode": "sign-in", + "idToken": "firebase-id-token-from-device" +} +``` + +Response: + +```json +{ + "sessionToken": "firebase-id-token-from-device", + "refreshToken": null, + "expiresInSeconds": 3600, + "user": { + "id": "user-uuid", + "phone": "+15551234567" + }, + "staff": { + "staffId": "staff-uuid" + }, + "requiresProfileSetup": false, + "requestId": "uuid" +} +``` + +Important: + +- `refreshToken` is expected to be `null` in this path +- refresh remains owned by Firebase Auth SDK on the device + +## Step 2B: server SMS path (`IDENTITY_TOOLKIT_SMS`) + +If `start` returned `sessionInfo`, frontend can call: + +```json +{ + "mode": "sign-in", + "sessionInfo": "firebase-session-info", + "code": "123456" +} +``` + +The backend exchanges `sessionInfo + code` with Identity Toolkit and returns the hydrated auth envelope. + +## 5) Sign-out + +Routes: + +- `POST /auth/sign-out` +- `POST /auth/client/sign-out` +- `POST /auth/staff/sign-out` + +All sign-out routes require: + +```http +Authorization: Bearer +``` + +What sign-out does: + +- revokes backend-side Firebase sessions for that user +- frontend should still clear local Firebase Auth state with `FirebaseAuth.instance.signOut()` + +## 6) Session endpoint + +Route: + +- `GET /auth/session` + +Headers: + +```http +Authorization: Bearer +``` + +Use it for: + +- app startup hydration +- role validation +- deciding whether this app should allow the current signed-in user + +Do not use it as: + +- a refresh endpoint +- a login endpoint + +## 7) Error contract + +All auth routes use the standard V2 error envelope: + +```json +{ + "code": "STRING_CODE", + "message": "Human readable message", + "details": {}, + "requestId": "uuid" +} +``` + +Common auth failures: + +- `UNAUTHENTICATED` +- `FORBIDDEN` +- `VALIDATION_ERROR` +- `AUTH_PROVIDER_ERROR` + +## 8) Troubleshooting + +### Staff sign-in does not work, but endpoints are reachable + +The most likely causes are: + +1. frontend expected `POST /auth/staff/phone/start` to always return `sessionInfo` +2. frontend did not complete Firebase phone verification on-device +3. frontend called `POST /auth/staff/phone/verify` without a valid Firebase `idToken` +4. frontend phone-auth setup in Firebase mobile config is incomplete + +### `POST /auth/staff/phone/start` returns `CLIENT_FIREBASE_SDK` + +That is expected for the normal mobile flow when no recaptcha or integrity tokens are sent. + +### There is no `/auth/refresh` + +That is also expected right now. + +Refresh is handled by Firebase Auth SDK on the client. diff --git a/docs/BACKEND/API_GUIDES/V2/command-api.md b/docs/BACKEND/API_GUIDES/V2/command-api.md new file mode 100644 index 00000000..bbf9d9fd --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/command-api.md @@ -0,0 +1,229 @@ +# V2 Command API + +Use `command-api-v2` for write actions that change business state. + +Base URL: + +```text +https://krow-command-api-v2-e3g6witsvq-uc.a.run.app +``` + +## 1) Required headers + +```http +Authorization: Bearer +Idempotency-Key: +Content-Type: application/json +``` + +## 2) Route summary + +| Method | Route | Purpose | +| --- | --- | --- | +| `POST` | `/commands/orders/create` | Create order with shifts and roles | +| `POST` | `/commands/orders/:orderId/update` | Update mutable order fields | +| `POST` | `/commands/orders/:orderId/cancel` | Cancel order and related eligible records | +| `POST` | `/commands/shifts/:shiftId/assign-staff` | Assign workforce to shift role | +| `POST` | `/commands/shifts/:shiftId/accept` | Accept an assigned shift | +| `POST` | `/commands/shifts/:shiftId/change-status` | Move shift to a new valid status | +| `POST` | `/commands/attendance/clock-in` | Record clock-in event | +| `POST` | `/commands/attendance/clock-out` | Record clock-out event | +| `POST` | `/commands/businesses/:businessId/favorite-staff` | Add favorite staff | +| `DELETE` | `/commands/businesses/:businessId/favorite-staff/:staffId` | Remove favorite staff | +| `POST` | `/commands/assignments/:assignmentId/reviews` | Create or update staff review | +| `GET` | `/readyz` | Ready check | + +## 3) Order create + +```text +POST /commands/orders/create +``` + +Request body: + +```json +{ + "tenantId": "uuid", + "businessId": "uuid", + "vendorId": "uuid", + "orderNumber": "ORD-1001", + "title": "Cafe Event Staffing", + "serviceType": "EVENT", + "shifts": [ + { + "shiftCode": "SHIFT-1", + "title": "Morning Shift", + "startsAt": "2026-03-12T08:00:00.000Z", + "endsAt": "2026-03-12T16:00:00.000Z", + "requiredWorkers": 2, + "roles": [ + { + "roleCode": "BARISTA", + "roleName": "Barista", + "workersNeeded": 2 + } + ] + } + ] +} +``` + +## 4) Order update + +```text +POST /commands/orders/:orderId/update +``` + +Required body fields: +- `tenantId` +- at least one mutable field such as `title`, `description`, `vendorId`, `serviceType`, `startsAt`, `endsAt`, `locationName`, `locationAddress`, `latitude`, `longitude`, `notes`, `metadata` + +You can also send `null` to clear nullable fields. + +## 5) Order cancel + +```text +POST /commands/orders/:orderId/cancel +``` + +Example request: + +```json +{ + "tenantId": "uuid", + "reason": "Client cancelled" +} +``` + +## 6) Shift assign staff + +```text +POST /commands/shifts/:shiftId/assign-staff +``` + +Example request: + +```json +{ + "tenantId": "uuid", + "shiftRoleId": "uuid", + "workforceId": "uuid", + "applicationId": "uuid" +} +``` + +## 7) Shift accept + +```text +POST /commands/shifts/:shiftId/accept +``` + +Example request: + +```json +{ + "shiftRoleId": "uuid", + "workforceId": "uuid" +} +``` + +## 8) Shift status change + +```text +POST /commands/shifts/:shiftId/change-status +``` + +Example request: + +```json +{ + "tenantId": "uuid", + "status": "PENDING_CONFIRMATION", + "reason": "Waiting for worker confirmation" +} +``` + +Allowed status values: +- `DRAFT` +- `OPEN` +- `PENDING_CONFIRMATION` +- `ASSIGNED` +- `ACTIVE` +- `COMPLETED` +- `CANCELLED` + +## 9) Attendance + +### Clock in + +```text +POST /commands/attendance/clock-in +``` + +### Clock out + +```text +POST /commands/attendance/clock-out +``` + +Example request body for both: + +```json +{ + "assignmentId": "uuid", + "sourceType": "NFC", + "sourceReference": "iphone-15-pro-max", + "nfcTagUid": "NFC-DEMO-ANA-001", + "deviceId": "device-123", + "latitude": 37.422, + "longitude": -122.084, + "accuracyMeters": 8, + "capturedAt": "2026-03-11T17:15:00.000Z" +} +``` + +## 10) Favorite staff + +### Add favorite + +```text +POST /commands/businesses/:businessId/favorite-staff +``` + +### Remove favorite + +```text +DELETE /commands/businesses/:businessId/favorite-staff/:staffId +``` + +Request body when adding: + +```json +{ + "tenantId": "uuid", + "staffId": "uuid" +} +``` + +## 11) Staff review + +```text +POST /commands/assignments/:assignmentId/reviews +``` + +Example request: + +```json +{ + "tenantId": "uuid", + "businessId": "uuid", + "staffId": "uuid", + "rating": 5, + "reviewText": "Strong shift performance", + "tags": ["punctual", "professional"] +} +``` + +## 12) Live status + +These routes were live-tested on `2026-03-11` against the deployed dev service and `krow-sql-v2`. diff --git a/docs/BACKEND/API_GUIDES/V2/core-api.md b/docs/BACKEND/API_GUIDES/V2/core-api.md new file mode 100644 index 00000000..adc0e3a4 --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/core-api.md @@ -0,0 +1,203 @@ +# V2 Core API + +Use `core-api-v2` for backend capabilities that should not live in the client. + +Base URL: + +```text +https://krow-core-api-v2-e3g6witsvq-uc.a.run.app +``` + +## 1) Route summary + +| Method | Route | Purpose | +| --- | --- | --- | +| `POST` | `/core/upload-file` | Upload file to Google Cloud Storage | +| `POST` | `/core/create-signed-url` | Generate a read URL for an uploaded file | +| `POST` | `/core/invoke-llm` | Run a model call | +| `POST` | `/core/rapid-orders/transcribe` | Turn uploaded audio into text | +| `POST` | `/core/rapid-orders/parse` | Turn order text into structured order data | +| `POST` | `/core/verifications` | Create a verification job | +| `GET` | `/core/verifications/:verificationId` | Fetch verification status | +| `POST` | `/core/verifications/:verificationId/review` | Apply manual review decision | +| `POST` | `/core/verifications/:verificationId/retry` | Retry a verification job | +| `GET` | `/health` | Health check | + +## 2) Upload file + +Route: + +```text +POST /core/upload-file +``` + +Send multipart form data: +- `file`: required +- `category`: optional string +- `visibility`: `public` or `private` + +Example response: + +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "contentType": "application/pdf", + "size": 12345, + "bucket": "krow-workforce-dev-private", + "path": "uploads//file.pdf", + "requestId": "uuid" +} +``` + +## 3) Create signed URL + +Route: + +```text +POST /core/create-signed-url +``` + +Example request: + +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "expiresInSeconds": 300 +} +``` + +Example response: + +```json +{ + "signedUrl": "https://...", + "expiresAt": "2026-03-11T18:30:00.000Z", + "requestId": "uuid" +} +``` + +## 4) Invoke model + +Route: + +```text +POST /core/invoke-llm +``` + +Example request: + +```json +{ + "prompt": "Summarize this staffing request", + "fileUrls": ["gs://krow-workforce-dev-private/uploads//notes.pdf"], + "responseJsonSchema": { + "type": "object", + "properties": { + "summary": { "type": "string" } + }, + "required": ["summary"] + } +} +``` + +Example response: + +```json +{ + "result": { + "summary": "..." + }, + "model": "vertex model name", + "latencyMs": 1200, + "requestId": "uuid" +} +``` + +## 5) Rapid order helpers + +### Transcribe + +```text +POST /core/rapid-orders/transcribe +``` + +Example request: + +```json +{ + "audioFileUri": "gs://krow-workforce-dev-private/uploads//note.m4a", + "locale": "en-US", + "promptHints": ["staffing order", "shift details"] +} +``` + +### Parse + +```text +POST /core/rapid-orders/parse +``` + +Example request: + +```json +{ + "text": "Need two baristas tomorrow from 8am to 4pm at Google Mountain View Cafe", + "locale": "en-US", + "timezone": "America/Los_Angeles" +} +``` + +## 6) Verification routes + +### Create verification + +```text +POST /core/verifications +``` + +Example request: + +```json +{ + "type": "attire", + "subjectType": "staff", + "subjectId": "staff-uuid", + "fileUri": "gs://krow-workforce-dev-private/uploads//attire.jpg", + "rules": { + "label": "black shoes" + } +} +``` + +### Get verification + +```text +GET /core/verifications/:verificationId +``` + +### Manual review + +```text +POST /core/verifications/:verificationId/review +``` + +Example request: + +```json +{ + "decision": "APPROVED", + "note": "Manual review passed" +} +``` + +### Retry + +```text +POST /core/verifications/:verificationId/retry +``` + +## 7) Caveat + +Verification state is not yet stored in `krow-sql-v2`. + +Use these routes now for frontend integration, but do not depend on verification history being durable until the persistence work lands. diff --git a/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md new file mode 100644 index 00000000..b594eb5b --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/mobile-api-gap-analysis.md @@ -0,0 +1,47 @@ +# Mobile API Reconciliation + +Source compared against implementation: + +- `mobile-backend-api-specification.md` + +## Result + +The current mobile v2 surface is implemented behind the unified gateway and validated live in `dev`. + +That includes: + +- auth session routes +- client dashboard, billing, coverage, hubs, vendor lookup, managers, team members, orders, and reports +- client order, hub, coverage review, and invoice write flows +- staff dashboard, availability, payments, shifts, profile sections, documents, attire, certificates, bank accounts, benefits, privacy, and frequently asked questions +- staff availability, tax forms, emergency contacts, bank account, shift decision, clock-in/out, and swap write flows +- upload and verification flows for profile photo, government document, attire, and certificates +- attendance policy enforcement, geofence incident review, background location-stream ingest, and queued manager alerts + +## What was validated live + +The live smoke executed successfully against: + +- `https://krow-api-v2-933560802882.us-central1.run.app` +- Firebase demo users +- `krow-sql-v2` +- `krow-core-api-v2` +- `krow-command-api-v2` +- `krow-query-api-v2` + +The validation script is: + +```bash +node backend/unified-api/scripts/live-smoke-v2-unified.mjs +``` + +## Remaining work + +The remaining items are not blockers for current mobile frontend migration. + +They are follow-up items: + +- extend the same unified pattern to new screens added after the current mobile specification +- add stronger contract automation around the unified route surface +- add a device-token registry and dispatch worker on top of `notification_outbox` +- keep refining reporting and financial read models as product scope expands diff --git a/docs/BACKEND/API_GUIDES/V2/query-api.md b/docs/BACKEND/API_GUIDES/V2/query-api.md new file mode 100644 index 00000000..8e64ba59 --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/query-api.md @@ -0,0 +1,151 @@ +# V2 Query API + +Use `query-api-v2` for implemented read screens in the v2 clients. + +Base URL: + +```text +https://krow-query-api-v2-e3g6witsvq-uc.a.run.app +``` + +## 1) Required header + +```http +Authorization: Bearer +``` + +## 2) Route summary + +| Method | Route | Purpose | +| --- | --- | --- | +| `GET` | `/query/tenants/:tenantId/orders` | Order list | +| `GET` | `/query/tenants/:tenantId/orders/:orderId` | Order detail with shifts and roles | +| `GET` | `/query/tenants/:tenantId/businesses/:businessId/favorite-staff` | Favorite staff list | +| `GET` | `/query/tenants/:tenantId/staff/:staffId/review-summary` | Staff rating summary and recent reviews | +| `GET` | `/query/tenants/:tenantId/assignments/:assignmentId/attendance` | Attendance session and event detail | +| `GET` | `/readyz` | Ready check | + +## 3) Order list + +```text +GET /query/tenants/:tenantId/orders +``` + +Optional query params: +- `businessId` +- `status` +- `limit` +- `offset` + +Response shape: + +```json +{ + "items": [ + { + "id": "uuid", + "orderNumber": "ORD-1001", + "title": "Cafe Event Staffing", + "status": "OPEN", + "serviceType": "EVENT", + "startsAt": "2026-03-12T08:00:00.000Z", + "endsAt": "2026-03-12T16:00:00.000Z", + "businessId": "uuid", + "businessName": "Google Mountain View Cafes", + "vendorId": "uuid", + "vendorName": "Legendary Staffing Pool A", + "shiftCount": 1, + "requiredWorkers": 2, + "assignedWorkers": 1 + } + ], + "requestId": "uuid" +} +``` + +## 4) Order detail + +```text +GET /query/tenants/:tenantId/orders/:orderId +``` + +Response shape: + +```json +{ + "id": "uuid", + "orderNumber": "ORD-1001", + "title": "Cafe Event Staffing", + "status": "OPEN", + "businessId": "uuid", + "businessName": "Google Mountain View Cafes", + "vendorId": "uuid", + "vendorName": "Legendary Staffing Pool A", + "shifts": [ + { + "id": "uuid", + "shiftCode": "SHIFT-1", + "title": "Morning Shift", + "status": "OPEN", + "startsAt": "2026-03-12T08:00:00.000Z", + "endsAt": "2026-03-12T16:00:00.000Z", + "requiredWorkers": 2, + "assignedWorkers": 1, + "roles": [ + { + "id": "uuid", + "roleCode": "BARISTA", + "roleName": "Barista", + "workersNeeded": 2, + "assignedCount": 1 + } + ] + } + ], + "requestId": "uuid" +} +``` + +## 5) Favorite staff list + +```text +GET /query/tenants/:tenantId/businesses/:businessId/favorite-staff +``` + +Optional query params: +- `limit` +- `offset` + +## 6) Staff review summary + +```text +GET /query/tenants/:tenantId/staff/:staffId/review-summary +``` + +Optional query params: +- `limit` + +Response includes: +- staff identity +- average rating +- rating count +- recent reviews + +## 7) Assignment attendance detail + +```text +GET /query/tenants/:tenantId/assignments/:assignmentId/attendance +``` + +Response includes: +- assignment status +- shift info +- attendance session +- ordered attendance events +- NFC and geofence validation fields + +## 8) Current boundary + +Frontend should use only these documented reads on `query-api-v2`. + +Do not point dashboard, reports, finance, or other undocumented list/detail views here yet. diff --git a/docs/BACKEND/API_GUIDES/V2/unified-api.md b/docs/BACKEND/API_GUIDES/V2/unified-api.md new file mode 100644 index 00000000..c9778dea --- /dev/null +++ b/docs/BACKEND/API_GUIDES/V2/unified-api.md @@ -0,0 +1,288 @@ +# Unified API V2 + +Frontend should use this service as the single base URL: + +- `https://krow-api-v2-933560802882.us-central1.run.app` + +The gateway keeps backend services separate internally, but frontend should treat it as one API. + +## 1) Auth routes + +Full auth behavior, including staff phone flow and refresh rules, is documented in [Authentication](./authentication.md). + +### Client auth + +- `POST /auth/client/sign-in` +- `POST /auth/client/sign-up` +- `POST /auth/client/sign-out` + +### Staff auth + +- `POST /auth/staff/phone/start` +- `POST /auth/staff/phone/verify` +- `POST /auth/staff/sign-out` + +### Shared auth + +- `GET /auth/session` +- `POST /auth/sign-out` + +## 2) Client routes + +### Client reads + +- `GET /client/session` +- `GET /client/dashboard` +- `GET /client/reorders` +- `GET /client/billing/accounts` +- `GET /client/billing/invoices/pending` +- `GET /client/billing/invoices/history` +- `GET /client/billing/current-bill` +- `GET /client/billing/savings` +- `GET /client/billing/spend-breakdown` +- `GET /client/coverage` +- `GET /client/coverage/stats` +- `GET /client/coverage/core-team` +- `GET /client/coverage/incidents` +- `GET /client/hubs` +- `GET /client/cost-centers` +- `GET /client/vendors` +- `GET /client/vendors/:vendorId/roles` +- `GET /client/hubs/:hubId/managers` +- `GET /client/team-members` +- `GET /client/orders/view` +- `GET /client/orders/:orderId/reorder-preview` +- `GET /client/reports/summary` +- `GET /client/reports/daily-ops` +- `GET /client/reports/spend` +- `GET /client/reports/coverage` +- `GET /client/reports/forecast` +- `GET /client/reports/performance` +- `GET /client/reports/no-show` + +### Client writes + +- `POST /client/devices/push-tokens` +- `DELETE /client/devices/push-tokens` +- `POST /client/orders/one-time` +- `POST /client/orders/recurring` +- `POST /client/orders/permanent` +- `POST /client/orders/:orderId/edit` +- `POST /client/orders/:orderId/cancel` +- `POST /client/hubs` +- `PUT /client/hubs/:hubId` +- `DELETE /client/hubs/:hubId` +- `POST /client/hubs/:hubId/assign-nfc` +- `POST /client/hubs/:hubId/managers` +- `POST /client/billing/invoices/:invoiceId/approve` +- `POST /client/billing/invoices/:invoiceId/dispute` +- `POST /client/coverage/reviews` +- `POST /client/coverage/late-workers/:assignmentId/cancel` + +## 3) Staff routes + +### Staff reads + +- `GET /staff/session` +- `GET /staff/dashboard` +- `GET /staff/profile-completion` +- `GET /staff/availability` +- `GET /staff/clock-in/shifts/today` +- `GET /staff/clock-in/status` +- `GET /staff/payments/summary` +- `GET /staff/payments/history` +- `GET /staff/payments/chart` +- `GET /staff/shifts/assigned` +- `GET /staff/shifts/open` +- `GET /staff/shifts/pending` +- `GET /staff/shifts/cancelled` +- `GET /staff/shifts/completed` +- `GET /staff/shifts/:shiftId` +- `GET /staff/profile/sections` +- `GET /staff/profile/personal-info` +- `GET /staff/profile/industries` +- `GET /staff/profile/skills` +- `GET /staff/profile/documents` +- `GET /staff/profile/attire` +- `GET /staff/profile/tax-forms` +- `GET /staff/profile/emergency-contacts` +- `GET /staff/profile/certificates` +- `GET /staff/profile/bank-accounts` +- `GET /staff/profile/benefits` +- `GET /staff/profile/time-card` +- `GET /staff/profile/privacy` +- `GET /staff/faqs` +- `GET /staff/faqs/search` + +### Staff writes + +- `POST /staff/profile/setup` +- `POST /staff/devices/push-tokens` +- `DELETE /staff/devices/push-tokens` +- `POST /staff/clock-in` +- `POST /staff/clock-out` +- `POST /staff/location-streams` +- `PUT /staff/availability` +- `POST /staff/availability/quick-set` +- `POST /staff/shifts/:shiftId/apply` +- `POST /staff/shifts/:shiftId/accept` +- `POST /staff/shifts/:shiftId/decline` +- `POST /staff/shifts/:shiftId/request-swap` +- `PUT /staff/profile/personal-info` +- `PUT /staff/profile/experience` +- `PUT /staff/profile/locations` +- `POST /staff/profile/emergency-contacts` +- `PUT /staff/profile/emergency-contacts/:contactId` +- `PUT /staff/profile/tax-forms/:formType` +- `POST /staff/profile/tax-forms/:formType/submit` +- `POST /staff/profile/bank-accounts` +- `PUT /staff/profile/privacy` + +## 4) Upload and verification routes + +These are exposed as direct unified aliases even though they are backed by `core-api-v2`. + +### Generic core aliases + +- `POST /upload-file` +- `POST /create-signed-url` +- `POST /invoke-llm` +- `POST /rapid-orders/transcribe` +- `POST /rapid-orders/parse` +- `POST /verifications` +- `GET /verifications/:verificationId` +- `POST /verifications/:verificationId/review` +- `POST /verifications/:verificationId/retry` + +### Staff upload aliases + +- `POST /staff/profile/photo` +- `POST /staff/profile/documents/:documentId/upload` +- `POST /staff/profile/attire/:documentId/upload` +- `POST /staff/profile/certificates` +- `DELETE /staff/profile/certificates/:certificateId` + +## 5) Notes that matter for frontend + +- `roleId` on `POST /staff/shifts/:shiftId/apply` is the concrete `shift_roles.id` for that shift, not the catalog role definition id. +- `accountType` on `POST /staff/profile/bank-accounts` accepts either lowercase or uppercase and is normalized by the backend. +- File upload routes return a storage path plus a signed URL. Frontend uploads the file directly to storage using that URL. +- Verification upload and review routes are live and were validated through document, attire, and certificate flows. Do not rely on long-lived verification history durability until the dedicated persistence slice is landed in `core-api-v2`. +- Attendance policy is explicit. Reads now expose `clockInMode` and `allowClockInOverride`. +- `clockInMode` values are: + - `NFC_REQUIRED` + - `GEO_REQUIRED` + - `EITHER` +- For `POST /staff/clock-in` and `POST /staff/clock-out`: + - send `nfcTagId` when clocking with NFC + - send `latitude`, `longitude`, and `accuracyMeters` when clocking with geolocation + - send `proofNonce` and `proofTimestamp` for attendance-proof logging; these are most important on NFC paths + - send `attestationProvider` and `attestationToken` only when the device has a real attestation result to forward + - send `overrideReason` only when the worker is bypassing a geofence failure and the shift/hub allows overrides +- `POST /staff/location-streams` is for the background tracking loop after a worker is already clocked in. +- `GET /client/coverage/incidents` is the review feed for geofence breaches, missing-location batches, and clock-in overrides. +- `POST /client/coverage/late-workers/:assignmentId/cancel` is the client-side recovery action when lateness is confirmed by incident evidence or elapsed grace time. +- Raw location stream payloads are stored in the private v2 bucket; SQL only stores the summary and incident index. +- Push delivery is backed by: + - SQL token registry in `device_push_tokens` + - durable queue in `notification_outbox` + - per-attempt delivery records in `notification_deliveries` + - private Cloud Run worker service `krow-notification-worker-v2` + - Cloud Scheduler job `krow-notification-dispatch-v2` + +### Push token request example + +```json +{ + "provider": "FCM", + "platform": "IOS", + "pushToken": "expo-or-fcm-device-token", + "deviceId": "iphone-15-pro-max", + "appVersion": "2.0.0", + "appBuild": "2000", + "locale": "en-US", + "timezone": "America/Los_Angeles" +} +``` + +Push-token delete requests may send `tokenId` or `pushToken` either: + +- as JSON in the request body +- or as query params on the `DELETE` URL + +Using query params is safer when the client stack or proxy is inconsistent about forwarding `DELETE` bodies. + +### Clock-in request example + +```json +{ + "shiftId": "uuid", + "sourceType": "GEO", + "deviceId": "iphone-15-pro", + "latitude": 37.4221, + "longitude": -122.0841, + "accuracyMeters": 12, + "proofNonce": "nonce-generated-on-device", + "proofTimestamp": "2026-03-16T09:00:00.000Z", + "overrideReason": "Parking garage entrance is outside the marked hub geofence", + "capturedAt": "2026-03-16T09:00:00.000Z" +} +``` + +### Location-stream batch example + +```json +{ + "shiftId": "uuid", + "sourceType": "GEO", + "deviceId": "iphone-15-pro", + "points": [ + { + "capturedAt": "2026-03-16T09:15:00.000Z", + "latitude": 37.4221, + "longitude": -122.0841, + "accuracyMeters": 12 + }, + { + "capturedAt": "2026-03-16T09:30:00.000Z", + "latitude": 37.4301, + "longitude": -122.0761, + "accuracyMeters": 20 + } + ], + "metadata": { + "source": "background-workmanager" + } +} +``` + +### Coverage incidents response shape + +```json +{ + "items": [ + { + "incidentId": "uuid", + "assignmentId": "uuid", + "shiftId": "uuid", + "staffName": "Ana Barista", + "incidentType": "OUTSIDE_GEOFENCE", + "severity": "CRITICAL", + "status": "OPEN", + "clockInMode": "GEO_REQUIRED", + "overrideReason": null, + "message": "Worker drifted outside hub geofence during active monitoring", + "distanceToClockPointMeters": 910, + "withinGeofence": false, + "occurredAt": "2026-03-16T09:30:00.000Z" + } + ], + "requestId": "uuid" +} +``` + +## 6) Why this shape + +- frontend gets one host +- backend keeps reads, writes, and service helpers separated +- routing can change internally later without forcing frontend rewrites diff --git a/docs/BACKEND/API_GUIDES/mobile-backend-api-specification.md b/docs/BACKEND/API_GUIDES/mobile-backend-api-specification.md new file mode 100644 index 00000000..f91a6b16 --- /dev/null +++ b/docs/BACKEND/API_GUIDES/mobile-backend-api-specification.md @@ -0,0 +1,1689 @@ +# KROW Mobile Backend API Specification +> **Generated:** 2026-03-12 +--- + +## Client Application + +--- + +### Feature: Authentication + +#### API - 1 + +API Type: POST +Description: +Signs in a client user using email and password. Authenticates the user against the identity provider and returns a session token. + +Inputs: +- Email address of the user. +- Password of the user. + +Returns: +- Authenticated user profile (identifier, name, email, role). +- Session token for subsequent authenticated requests. + +--- + +#### API - 2 + +API Type: POST +Description: +Registers a new client user and creates an associated business entity. The backend creates the identity record, the user record, and a business entity in a single transactional flow. Rolls back all created resources if any step fails. + +Inputs: +- Company name for the new business. +- Email address of the user. +- Password for the new account. + +Returns: +- Authenticated user profile (identifier, name, email, role). +- Newly created business identifier. +- Session token for subsequent authenticated requests. + +--- + +#### API - 3 + +API Type: POST +Description: +Signs out the currently authenticated client user. Invalidates the active session and clears any server-side session state. + +Inputs: +- Session token (via authorization header). + +Returns: +- Confirmation of successful sign-out. + +--- + +### Feature: Billing + +#### API - 1 + +API Type: GET +Description: +Returns all bank accounts associated with the client's business. + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- List of bank accounts, each containing: account identifier, bank name, account number (masked), routing number, account type, and primary status. + +--- + +#### API - 2 + +API Type: GET +Description: +Returns all pending invoices (open or disputed) for the business. + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- List of pending invoices, each containing: invoice identifier, amount, status (open/disputed), due date, and associated vendor information. + +--- + +#### API - 3 + +API Type: GET +Description: +Returns the full invoice history for the business, including paid and completed invoices. + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- List of historical invoices, each containing: invoice identifier, amount, status, payment date, and associated vendor information. + +--- + +#### API - 4 + +API Type: GET +Description: +Returns the current outstanding bill amount for the active billing period (specific time period). + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- Current bill amount as a monetary value. + +--- + +#### API - 5 + +API Type: GET +Description: +Returns the total savings amount accumulated by the business. + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- Savings amount as a monetary value. + +--- + +#### API - 6 + +API Type: GET +Description: +Returns a detailed spending breakdown for a given billing period, grouped by role and industry. + +Inputs: +- Business identifier (derived from authenticated session). +- Billing period (specific time period). + +Returns: +- List of spending line items, each containing: category name, amount, and percentage of total spend. + +--- + +#### API - 7 + +API Type: POST +Description: +Approves a pending invoice, marking it as accepted for payment. + +Inputs: +- Invoice identifier. + +Returns: +- Updated invoice status (approved). + +--- + +#### API - 8 + +API Type: POST +Description: +Disputes an invoice, recording the dispute reason and changing its status. + +Inputs: +- Invoice identifier. +- Reason for the dispute. + +Returns: +- Updated invoice status (disputed). + +--- + +### Feature: Client Coverage + +#### API - 1 + +API Type: GET +Description: +Returns all shift coverage information for a specific date. Includes shift details along with assigned worker status (confirmed, checked-in, en route, late). + +Inputs: +- Business identifier (derived from authenticated session). +- Date for which to retrieve coverage. + +Returns: +- List of coverage shifts, each containing: shift identifier, role name, time range, required worker count, and list of assigned workers with their current status and check-in times. + +--- + +#### API - 2 + +API Type: GET +Description: +Returns aggregated coverage statistics for a specific date. Provides summary counts of staffing needs vs. fulfillment. + +Inputs: +- Business identifier (derived from authenticated session). +- Date for which to retrieve statistics. + +Returns: +- Total coverage percentage. +- Total positions needed. +- Total positions confirmed. +- Total workers checked in. +- Total workers en route. +- Total workers late. + +--- + +#### API - 3 + +API Type: POST +Description: +Rate a worker. + +Inputs: +- worker id. +- Number of starts of a user. +- Mark as favourite +- Issue flags. +- Feedback + +Returns: +- return those exact values, that we updated. + +--- + +#### API - 4 + +API Type: POST +Description: +Cancel a worker who's running late. + +Inputs: +- worker id. +- order details and shift details if required. + +Returns: +- + +Note: +- Also, by doing this the system need to automatically prompts to search the workers for the shift. + +--- + +#### API - 5 + +API Type: GET +Description: +View the workers of the core team. + +--- + +### Feature: Home (Client Dashboard) + +#### API - 1 + +API Type: GET +Description: +Returns aggregated dashboard data for the client's business, including spending summaries and shift statistics for the current and upcoming week. + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- Spending oversights + - Weekly spending amount. + - Next 7 days projected spending. +- Coverage. + - Today's needed workers + - Today's filled workers + - Today's position opend +- Live activity + - Today's worker's running late + - Avg. today's cost + - Checked in + - Today's position opend + +--- + +#### API - 2 + +API Type: GET +Description: +Returns user session data including the business name and user profile information for dashboard display. + +Inputs: +- User identifier (derived from authenticated session). + +Returns: +- User name. +- Business name. +- Business identifier. + +--- + +#### API - 3 + +API Type: GET +Description: +Returns a list of recent orders that are eligible for reordering. + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- List of recent reorder items, each containing: order identifier, order title, date, hub name, and number of positions, order type. + +--- + +### Feature: Hubs + +#### API - 1 + +API Type: GET +Description: +Returns all team hubs belonging to the client's business. + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- List of hubs, each containing: hub identifier, name, full address, coordinates (latitude/longitude), city, state, zip code, associated cost center, and NFC tag identifier if assigned. + +--- + +#### API - 2 + +API Type: GET +Description: +Returns all cost centers (departments) available for the business. + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- List of cost centers, each containing: cost center identifier and name. + +--- + +#### API - 3 + +API Type: POST +Description: +Creates a new team hub for the business. Geocodes the provided address and creates associated team and cost center records as needed. + +Inputs: +- Hub name. +- Full address string. +- Google Place ID (optional, for geocoding). +- Latitude and longitude (optional, derived if place ID provided). +- City, state, street, country, zip code (optional). +- Cost center identifier (optional). + +Returns: +- Newly created hub with full details including: hub identifier, name, resolved address, coordinates, and associated cost center. + +--- + +#### API - 4 + +API Type: PUT +Description: +Updates an existing team hub's information. + +Inputs: +- Hub identifier. +- Updated hub name (optional). +- Updated address and location fields (optional). +- Updated cost center identifier (optional). + +Returns: +- Updated hub with full details. + +--- + +#### API - 5 + +API Type: DELETE +Description: +Deletes a team hub. Validates that no active orders reference the hub before deletion. + +Inputs: +- Hub identifier. + +Returns: +- Confirmation of successful deletion (cannot delete if alreaddy order's are assigned to it) + +--- + +#### API - 6 + +API Type: POST +Description: +Assigns an NFC tag to a team hub for contactless check-in support. + +Inputs: +- Hub identifier. +- NFC tag identifier. + +Returns: +- Confirmation of successful assignment. + +--- + +### Feature: Orders — Create Order + +#### API - 1 + +API Type: GET +Description: +Returns the list of vendors available to the client's business for order creation. + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- List of vendors, each containing: vendor identifier and vendor name. + +--- + +#### API - 2 + +API Type: GET +Description: +Returns the list of worker roles available from a specific vendor. + +Inputs: +- Vendor identifier. + +Returns: +- List of roles, each containing: role identifier, role name, and hourly rate. + +--- + +#### API - 3 + +API Type: GET +Description: +Returns the list of hubs owned by the authenticated business for selection during order creation. + +Inputs: +- Business identifier (derived from authenticated session). + +Returns: +- List of hubs, each containing: hub identifier and hub name. + +--- + +#### API - 4 + +API Type: GET +Description: +Returns the list of managers assigned to a specific hub for order assignment. + +Inputs: +- Hub identifier. + +Returns: +- List of managers, each containing: manager identifier and manager name. + +--- + +#### API - 4 + +API Type: POST +Description: +Create a new manager for the hub from the team memebers of the business + +Inputs: +- Hub identifier. +- team memeber id + +Returns: +- + +--- + +#### API - 5 + +API Type: POST +Description: +Creates a new one-time order with one or more positions. This is a transactional operation that creates the order record, an associated shift, and individual shift role entries for each requested position. Updates the order with shift references upon completion. + +Inputs: +- Hub identifier. +- Vendor identifier. +- Event name. +- Order date. +- List of positions, each containing: + - Role identifier. + - Number of workers needed. + - Start time. + - End time. + - Lunch break duration and whether it is paid. + - Hourly rate. + +Returns: +- Created order identifier. +- Created shift identifier. +- Order status (posted). + +--- + +#### API - 6 + +API Type: POST +Description: +Creates a new recurring order that repeats on a defined schedule with specified positions. + +Inputs: +- Hub identifier. +- Vendor identifier (optional). +- Event name (optional). +- Start date and end date defining the recurrence window. +- Recurrence pattern (e.g., days of week). +- List of positions with role, count, time range, break, and rate details. + +Returns: +- Created order identifier. +- Order status (posted). + +--- + +#### API - 7 + +API Type: POST +Description: +Creates a new permanent staffing order for ongoing coverage at a hub. + +Inputs: +- Hub identifier. +- Vendor identifier (optional). +- Start date. +- List of positions with role, count, time range, break, and rate details. + +Returns: +- Created order identifier. +- Order status (posted). + +--- + +#### API - 9 + +API Type: POST +Description: +Edit an existing past order/shift. + - need one for each type (one-time, recurring and permanent) + +Inputs: +- Original order identifier. +- This gets the details for each order + +Returns: +- Newly created order identifier. +- Order status (posted). + +Note: +- Need to verify if whether we are changing the only the current shift or the entire order when editing. + +--- + +#### API - 10 + +API Type: GET +Description: +Returns the full details of a previous order for reorder preview purposes. + +Inputs: +- Order identifier. + +Returns: +- Order details including: hub, vendor, event name, positions (role, count, times, rates), and original date. + +--- + +### Feature: Orders — View Orders + +#### API - 1 + +API Type: GET +Description: +Returns all shift roles (order line items) for a given date range, grouped by day. Each item represents a specific role on a specific shift within an order. + +Inputs: +- Business identifier (derived from authenticated session). +- Start date of the range. +- End date of the range. + +Returns: +- List of order items, each containing: item identifier, order identifier, order type (one-time/recurring/permanent), role name, date, start and end times, required worker count, filled count, hourly rate, total cost, location name, current status and lists of accepted worker details, each containing: application identifier, worker name, role, and confirmation status. + +--- + +### Feature: Reports + +#### API - 1 + +API Type: GET +Description: +Returns a summary across all report categories for a given date range. Provides top-level metrics for the reports dashboard. + +Inputs: +- Business identifier (derived from authenticated session). +- Start date of the report period. +- End date of the report period. + +Returns: +- Total shifts. +- Total spend. +- Average coverage percentage. +- Average performance score. +- No-show count. +- Forecast accuracy percentage. + +--- + +#### API - 2 + +API Type: GET +Description: +Returns the daily operations report for a specific date. Includes shift-level metrics broken down by role and status. + +Inputs: +- Business identifier (derived from authenticated session). +- Report date. + +Returns: +- Total shifts for the day. +- Total workers deployed. +- Total hours worked. +- On-time arrival percentage. +- List of shifts with role, status, worker count, start and end times. + +--- + +#### API - 3 + +API Type: GET +Description: +Returns the spending report for a date range. Includes spend grouped by time period and by industry/role. + +Inputs: +- Business identifier (derived from authenticated session). +- Start date of the report period. +- End date of the report period. + +Returns: +- Total spend for the period. +- Spend grouped by day or week (for charting). +- Spend breakdown by role and industry category. + +--- + +#### API - 4 + +API Type: GET +Description: +Returns the coverage report for a date range. Shows how well staffing needs were met over time. + +Inputs: +- Business identifier (derived from authenticated session). +- Start date of the report period. +- End date of the report period. + +Returns: +- Average coverage percentage for the period +- Filled workers for the period +- Need workers for the period. +- Daily coverage rates (for charting) for each day in the period + +--- + +#### API - 5 + +API Type: GET +Description: +Returns the forecast report for a date range. Projects future staffing needs based on historical patterns. + +Inputs: +- Business identifier (derived from authenticated session). +- Start date of the report period. +- End date of the report period. + +Returns: +- Forecast spend for the period +- Average weekly for the period. +- Total shifts for the period. +- Total worker hours for the period. +- Spending forecast for each week in the period (for charting). +- Weekly breakdown + - no. of shifts + - total no. of worker hours. + - avg/shift cost + +--- + +#### API - 6 + +API Type: GET +Description: +Returns the performance report for a date range. Includes worker and shift quality metrics. + +Inputs: +- Business identifier (derived from authenticated session). +- Start date of the report period. +- End date of the report period. + +Returns: +- Average performance score (whether excellent or not) +- Fill Rate (with met or not). +- Completion rate (with met or not). +- On-time rate (with met or not). +- Avg. Fill Time (with met or not). +- Total Shifts covered. +- No-show rate + +--- + +#### API - 7 + +API Type: GET +Description: +Returns the no-show report for a date range. Tracks workers who failed to appear for assigned shifts. + +Inputs: +- Business identifier (derived from authenticated session). +- Start date of the report period. +- End date of the report period. + +Returns: +- Total no-show count. +- No-show rate as a percentage. +- No. of workers who no-showed. +- List of workers who no-showed. + - Risk status of whether they would it do it again + - List of no-show incidents with shift, role, date for each worker + +--- + +### Feature: Settings + +#### API - 1 + +API Type: POST +Description: +Signs out the currently authenticated client user and clears all server-side session data. + +Inputs: +- Session token (via authorization header). + +Returns: +- Confirmation of successful sign-out. + +--- + +--- + +## Staff Application + +--- + +### Feature: Authentication + +#### API - 1 + +API Type: POST +Description: +Initiates phone-based authentication by sending an OTP to the provided phone number. + +Inputs: +- Phone number of the staff user. + +Returns: +- Verification identifier to be used when submitting the OTP code. + +--- + +#### API - 2 + +API Type: POST +Description: +Verifies the OTP code submitted by the staff user. If the user account does not exist, creates a new user record. Returns authentication session and user profile. + +Inputs: +- Verification identifier (from the phone-initiation step). +- OTP code entered by the user. +- Authentication mode (sign-in or sign-up). + +Returns: +- Authenticated user profile (identifier, name, phone, role). +- Session token for subsequent authenticated requests. +- Flag indicating if this is a new user (profile setup required). + +--- + +#### API - 3 + +API Type: POST +Description: +Signs out the currently authenticated staff user and clears server-side session state. + +Inputs: +- Session token (via authorization header). + +Returns: +- Confirmation of successful sign-out. + +--- + +#### API - 4 + +API Type: POST +Description: +Submits the initial profile setup for a newly registered staff user. Creates the staff record with personal details, preferences, and skills. + +Inputs: +- Full name of the staff member. +- Bio (optional). +- Phone number +- List of preferred work locations (cities saved with Lat and Long). +- Maximum commute distance in miles. +- List of preferred industries. +- List of skills. + +Returns: +- Created staff profile identifier. +- Confirmation of successful profile creation. + +--- + +### Feature: Availability + +#### API - 1 + +API Type: GET +Description: +Returns the weekly availability schedule for the staff member over a given date range. The backend maps recurring weekly slots to specific dates in the requested range. + +Inputs: +- Staff identifier (derived from authenticated session). +- Start date of the range. +- End date of the range (typically start + 6 days). + +Returns: +- List of day availability entries, each containing: date, day of week, availability status (available/unavailable/partial), and time slots with start and end times. + +--- + +#### API - 2 + +API Type: PUT +Description: +Updates the recurring availability for a specific day of the week. Sets or modifies the time slots during which the staff member is available to work. + +Inputs: +- Staff identifier (derived from authenticated session). +- Day of week. +- Availability status (available/unavailable/partial). +- List of time slots with start and end times (for partial availability). + +Returns: +- Updated day availability entry with resolved slots. + +--- + +#### API - 3 + +API Type: POST +Description: +Applies a quick-set availability template across a date range. Supports preset patterns such as "available all days", "weekdays only", "weekends only", or "clear all". + +Inputs: +- Staff identifier (derived from authenticated session). +- Start date of the range. +- End date of the range. +- Quick-set type (all/weekdays/weekends/clear). + +Returns: +- Updated list of day availability entries for the full range. + +--- + +### Feature: Clock In + +#### API - 1 + +API Type: GET +Description: +Returns all shifts the staff member is assigned to for the current day, ordered by proximity to the current time. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of today's shifts, each containing: shift identifier, role name, location, start time, end time, and current attendance status. + +--- + +#### API - 2 + +API Type: GET +Description: +Returns the current attendance status of the staff member (e.g., not clocked in, clocked in, on break, clocked out). + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- Current attendance status. +- Active shift identifier (if clocked in). +- Clock-in timestamp (if clocked in). + +--- + +#### API - 3 + +API Type: POST +Description: +Records a clock-in event for the staff member on a specific shift. Validates that the shift is assigned to the user and within the allowed clock-in window. + +Inputs: +- Staff identifier (derived from authenticated session). +- Shift identifier. +- Notes (optional). + +Returns: +- Updated attendance status (clocked in). +- Clock-in timestamp. + +--- + +#### API - 4 + +API Type: POST +Description: +Records a clock-out event for the staff member. Captures total break time and optional notes. + +Inputs: +- Staff identifier (derived from authenticated session). +- Notes (optional). +- Break time in minutes (optional). +- Application identifier (optional, for specific shift tracking). + +Returns: +- Updated attendance status (clocked out). +- Clock-out timestamp. +- Total hours worked. + +--- + +### Feature: Home (Staff Dashboard) + +#### APIs + +API Type: GET +Description: +Returns the staff member's dashboard data including today's shifts, tomorrow's shifts, recommended shifts, and benefits. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- If possible create seperate endpoint for each of the following + - Staff member's name. + - List of today's confirmed shifts. + - List of tomorrow's confirmed shifts. + - List of recommended open shifts. + - List of active benefits with hours tracking. + +--- + +#### API - 2 + +API Type: GET +Description: +Returns the status of the worker profile completion status. + +Inputs: +- Workr id. + +Returns: +- If the worker profile is fully completed (if not they cannot apply to shifts and not show the clock-in, my shifts and assigned shifts). The profile is complete when the worker has properly filled + - First name + - Last name + - email + - phone nubmer + - preffered locations + - skills + - industries + - emergency contact + +--- + +### Feature: Payments + +#### API - 1 + +API Type: GET +Description: +Returns the payment summary for the staff member, for the given period. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- Total Earnings for the given period. + +--- + +#### API - 2 + +API Type: GET +Description: +Returns the payment history for the staff member filtered by time period. + +Inputs: +- Staff identifier (derived from authenticated session). +- Period filter (for the given period) + +Returns: +- List of payment entries, each containing: payment identifier, amount, date, status (paid/pending), associated shift name, location, hourly rate, and hours worked. + +--- + +#### API - 3 + +API Type: GET +Description: +Charting details (for the amount that the worker recieved) for the given period. + +Inputs: +- Staff identifier (derived from authenticated session). +- Period filter (for the given period) + +Returns: +- For each (Day/week/month: this value can depend on the period) the amount recieved. + +--- + +### Feature: Shifts + +#### API - 1 + +API Type: GET +Description: +Returns shifts assigned to the staff member within a date range (confirmed, request-swap and upcoming shifts). + +Inputs: +- Staff identifier (derived from authenticated session). +- Start date. +- End date. + +Returns: +- List of assigned shifts, each containing: shift identifier, role name, location, date, start time, end time, hourly rate, order type (one-time/recurring/permanent), and current status. + +--- + +#### API - 2 + +API Type: GET +Description: +Returns available open shifts that the staff member can apply for. Supports search and filtering by type. + +Inputs: +- Staff identifier (derived from authenticated session). +- Search query (optional). +- Shift type filter (optional). + - Worker location's [List] + - Radius from each location that user's like to work (ex: 20km from each preffered location) + +Returns: +- List of available shifts, each containing: shift identifier, role name, location, date, start time, end time, hourly rate, order type, instant book availability, and required worker count. +- These shifts should automatically filtered according to the user's preferneces provided (location, experiences etc.) + +--- + +#### API - 3 + +API Type: GET +Description: +Returns pending shift assignments awaiting the staff member's response (accept or decline). + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of pending shifts with full shift details and response deadline. + +--- + +#### API - 4 + +API Type: GET +Description: +Returns detailed information about a specific shift, including role-specific details. + +Inputs: +- Staff identifier (derived from authenticated session). +- Shift identifier. +- Role identifier (optional, for role-specific view). + +Returns: +- Complete shift details: shift identifier, title, description, location with address, date, start and end times, hourly rate, break policy, order type, required count, confirmed count, and the staff member's application status for this shift. + +--- + +#### API - 5 + +API Type: POST +Description: +Submits an application for an open shift. Supports instant booking where the application is immediately confirmed. For recurring and permanent orders, creates applications for all matching days. + +Inputs: +- Staff identifier (derived from authenticated session). +- Shift identifier. +- Role identifier (optional, to apply for a specific role on the shift). +- Instant book flag (true if the shift supports immediate confirmation). + +Returns: +- Application identifier. +- Application status (pending or confirmed if instant book). + +--- + +#### API - 6 + +API Type: POST +Description: +Accepts a pending shift assignment offered to the staff member. + +Inputs: +- Staff identifier (derived from authenticated session). +- Shift identifier. + +Returns: +- Updated application status (confirmed). + +--- + +#### API - 7 + +API Type: POST +Description: +Declines a pending shift assignment offered to the staff member. + +Inputs: +- Staff identifier (derived from authenticated session). +- Shift identifier. + +Returns: +- Updated application status (declined). + +--- + +#### API - 8 + +API Type: GET +Description: +Returns shifts that were previously assigned to the staff member but have been cancelled. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of cancelled shifts with shift details and cancellation reason. + +--- + +#### API - 9 + +API Type: GET +Description: +Returns the staff member's completed shift history. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of historical shifts with shift details, hours worked, and payment status. + +--- + +#### API - 10 + +API Type: GET +Description: +To use the "Request swap" feature, where the shift status is switched to an intermedidate state (not canceled) but it's starts to show on other workers find shift + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- + +--- + +### Feature: Profile Sections + +#### API - 1 + +API Type: GET +Description: +Returns the status of each of the following section + - worker personal info completion status. + - worker emergency contact completion status + - worker's experiences completion status. + - worker's attire section completion status. + - worker's tax form's completion status. + +Inputs: +- Workr id. + +Returns: +- Map of each section completion status. + + - The worker's personal info is complete when the worker has properly filled + - First name + - Last name + - email + - phone nubmer + - preffered locations + - If the worker has added at least one emergency contact. + - The worker's experiences section is complete when the worker has properly filled + - skills + - industries + - The worker's attire section is marked completed when they have uploaded all the required attire options. + - The worker's tax form section is marked completed when they have uploaded all the required attire items. + +--- + +### Feature: Profile Sections — Personal Info + +#### API - 1 + +API Type: GET +Description: +Returns the worker personal info + +Inputs: +- Workr id. + +Returns: +- Fields : first name, last name, bio, preferred locations, max distance, industries, skills. + +--- + +#### API - 2 + +API Type: PUT +Description: +Updates the staff member's profile information. Supports partial updates where only provided fields are modified. + +Inputs: +- Staff identifier. +- Updated fields (all optional): first name, last name, bio, preferred locations, email, phone number + + +Returns: +- Updated fields. + +--- + +#### API - 3 + +API Type: POST +Description: +Uploads a new profile photo for the staff member. + +Inputs: +- Staff identifier (derived from authenticated session). +- Photo file. + +Returns: +- URL of the uploaded profile photo. + +--- + +#### API - 4 + +API Type: PUT +Description: +Updates the staff member's preferred locations. + +Inputs: +- Staff identifier. + + +Returns: +- Updated fields : first name, last name, bio, preferred locations, email, phone number. + +--- + +### Feature: Profile Sections — Experience + +#### API - 1 + +API Type: GET +Description: +Returns the staff member's selected industries. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of industry names. + +--- + +#### API - 2 + +API Type: GET +Description: +Returns the staff member's selected skills. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of skill names. + +--- + +#### API - 3 + +API Type: PUT +Description: +Updates the staff member's industry and skill selections. + +Inputs: +- Staff identifier (derived from authenticated session). +- List of selected industries. +- List of selected skills. + +Returns: +- Confirmation of successful update. + +--- + +### Feature: Profile Sections — Emergency Contact + +#### API - 1 + +API Type: GET +Description: +Returns all emergency contacts for the staff member. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of emergency contacts, each containing: contact identifier, full name, phone number, and relationship type. + +--- + +#### API - 2 + +API Type: PUT +Description: +Edit an existing emergency contact + +Inputs: +- Staff identifier (derived from authenticated session). +- Updated fields: full name, phone number, and relationship type. + +Returns: +- Updated list of emergency contacts with assigned identifiers. + +--- + +#### API - 2 + +API Type: POST +Description: +Create a new emergency contact + +Inputs: +- Staff identifier (derived from authenticated session). +- Fields: full name, phone number, and relationship type. + +Returns: +- Updated list of emergency contacts with assigned identifiers. + +--- + +### Feature: Profile Sections — Tax Forms + +#### API - 1 + +API Type: GET +Description: +Returns all tax forms (I-9 and W-4) for the staff member. Auto-creates blank forms if none exist. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of tax forms, each containing: form type (I-9 or W-4), form status (not started/in progress/submitted), and all form-specific fields. + +--- + +#### API - 2 + +API Type: PUT +Description: +Saves a partial update to the I-9 tax form (draft state, not submitted). + +Inputs: +- Staff identifier (derived from authenticated session). +- I-9 form fields (all optional): citizenship status, document type, document number, alien/admission numbers, date of birth, Social Security Number, address fields. + +Returns: +- Updated I-9 form with current values and status. + +--- + +#### API - 3 + +API Type: POST +Description: +Submits the completed I-9 tax form. Changes the form status to submitted and validates all required fields. + +Inputs: +- Staff identifier (derived from authenticated session). +- Complete I-9 form data with all required fields. + +Returns: +- Submitted I-9 form with updated status. + +--- + +#### API - 4 + +API Type: PUT +Description: +Saves a partial update to the W-4 tax form (draft state, not submitted). + +Inputs: +- Staff identifier (derived from authenticated session). +- W-4 form fields (all optional): filing status, multiple jobs flag, dependent amounts, other income, deductions, extra withholding, exempt status. + +Returns: +- Updated W-4 form with current values and status. + +--- + +#### API - 5 + +API Type: POST +Description: +Submits the completed W-4 tax form. Changes the form status to submitted and validates all required fields. + +Inputs: +- Staff identifier (derived from authenticated session). +- Complete W-4 form data with all required fields. + +Returns: +- Submitted W-4 form with updated status. + +--- + +### Feature: Profile Sections — Certificates + +#### API - 1 + +API Type: GET +Description: +Returns all certificates (compliance documents) for the staff member. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of certificates, each containing: certification type, name, status (pending/verified/expired), file URL, expiry date, issuer, certificate number, and validation status. + +--- + +#### API - 2 + +API Type: POST +Description: +Uploads a new certificate file and initiates automated verification. The backend stores the file, generates a signed URL, creates a verification request, and persists the certificate record. + +Inputs: +- Staff identifier (derived from authenticated session). +- Certification type (e.g., food handler, CPR, guard card). +- Certificate name. +- Certificate file. +- Expiry date (optional). +- Issuer (optional). +- Certificate number (optional). + +Returns: +- Created certificate record with: assigned identifier, file URL, initial verification status, and all submitted metadata. + +--- + +#### API - 3 + +API Type: DELETE +Description: +Deletes a certificate record by compliance type. + +Inputs: +- Staff identifier (derived from authenticated session). +- Certification type to delete. + +Returns: +- Confirmation of successful deletion. + +--- + +### Feature: Profile Sections — Documents + +#### API - 1 + +API Type: GET +Description: +Returns all required documents and their current status for the staff member (e.g., government ID). + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of documents, each containing: document identifier, document type, status (not uploaded/pending/verified/rejected), and file URL if uploaded. + +--- + +#### API - 2 + +API Type: POST +Description: +Uploads a document file and initiates automated verification. The backend stores the file, generates a signed URL, creates a verification request, and updates the document record. + +Inputs: +- Staff identifier (derived from authenticated session). +- Document identifier (the specific required document slot). +- Document file. + +Returns: +- Updated document record with: file URL, initial verification status, and document metadata. + +--- + +### Feature: Profile Sections — Attire + +#### API - 1 + +API Type: GET +Description: +Returns the attire checklist options and the staff member's current photo status for each item. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of attire items, each containing: item identifier, item name, description, whether it is mandatory, photo URL if uploaded, and verification status (pending/approved/rejected). + +--- + +#### API - 2 + +API Type: POST +Description: +Uploads a photo for a specific attire item and initiates automated verification. The backend stores the photo, generates a signed URL, creates a verification request, polls for verification result, and updates the attire record. + +Inputs: +- Staff identifier (derived from authenticated session). +- Attire item identifier. +- Photo file. + +Returns: +- Updated attire item with: photo URL, verification status, and verification result details. + +--- + +### Feature: Profile Sections — Bank Account + +#### API - 1 + +API Type: GET +Description: +Returns all bank accounts on file for the staff member. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- List of bank accounts, each containing: account identifier, bank name, last four digits, account type (checking/savings), routing number, and whether it is the primary account. + +--- + +#### API - 2 + +API Type: POST +Description: +Adds a new bank account for the staff member. The first account added is automatically set as the primary account. + +Inputs: +- Staff identifier (derived from authenticated session). +- Bank name. +- Account number. +- Routing number. +- Account type (checking or savings). + +Returns: +- Created bank account record with: assigned identifier, masked account number, and primary status. + +--- + +### Feature: Profile Sections — Time Card + +#### API - 1 + +API Type: GET +Description: +Returns time card entries for the staff member for a specific month. Each entry represents a completed shift with calculated hours and pay. + +Inputs: +- Staff identifier (derived from authenticated session). +- Month and year. + +Returns: +- List of time card entries, each containing: date, shift name, location, clock-in time, clock-out time, total hours worked, hourly rate, and total pay for the entry. + +--- + +### Feature: Profile Sections — Privacy & Security + +#### API - 1 + +API Type: GET +Description: +Returns the current profile visibility setting for the staff member. + +Inputs: +- Staff identifier (derived from authenticated session). + +Returns: +- Profile visibility flag (true if profile is visible to potential employers). + +--- + +#### API - 2 + +API Type: PUT +Description: +Updates the profile visibility setting for the staff member. + +Inputs: +- Staff identifier (derived from authenticated session). +- Visibility flag (true or false). + +Returns: +- Updated visibility status. + +--- + +### Feature: Profile Sections — FAQs + +#### API - 1 + +API Type: GET +Description: +Returns all FAQ categories and their questions/answers. + +Inputs: +- None (static content). + +Returns: +- List of FAQ categories, each containing: category name and list of FAQ entries (question and answer pairs). + +--- + +#### API - 2 + +API Type: GET +Description: +Searches FAQs by keyword, matching against both questions and answers. + +Inputs: +- Search query string. + +Returns: +- Filtered list of FAQ categories containing only entries that match the search query. + +--- diff --git a/docs/MILESTONES/M4/planning/m4-api-catalog.md b/docs/MILESTONES/M4/planning/m4-api-catalog.md index 516ebf38..d4fc4f11 100644 --- a/docs/MILESTONES/M4/planning/m4-api-catalog.md +++ b/docs/MILESTONES/M4/planning/m4-api-catalog.md @@ -1,232 +1,10 @@ -# M4 API Catalog (Core Only) +# Moved -Status: Active -Date: 2026-02-24 -Owner: Technical Lead -Environment: dev +The canonical v2 backend API docs now live here: -## Frontend source of truth -Use this file and `docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md` for core endpoint consumption. +- `docs/BACKEND/API_GUIDES/V2/README.md` +- `docs/BACKEND/API_GUIDES/V2/core-api.md` +- `docs/BACKEND/API_GUIDES/V2/command-api.md` +- `docs/BACKEND/API_GUIDES/V2/query-api.md` -## Related next-slice contract -Verification pipeline design (attire, government ID, certification): -- `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md` - -## 1) Scope and purpose -This catalog defines the currently implemented core backend contract for M4. - -## 2) Global API rules -1. Route group in scope: `/core/*`. -2. Compatibility aliases in scope: -- `POST /uploadFile` -> `POST /core/upload-file` -- `POST /createSignedUrl` -> `POST /core/create-signed-url` -- `POST /invokeLLM` -> `POST /core/invoke-llm` -3. Auth model: -- `GET /health` is public in dev -- all other routes require `Authorization: Bearer ` -4. Standard error envelope: -```json -{ - "code": "STRING_CODE", - "message": "Human readable message", - "details": {}, - "requestId": "optional-request-id" -} -``` -5. Response header: -- `X-Request-Id` - -## 3) Core routes - -## 3.1 Upload file -1. Method and route: `POST /core/upload-file` -2. Request format: `multipart/form-data` -3. Fields: -- `file` (required) -- `visibility` (`public` or `private`, optional) -- `category` (optional) -4. Accepted types: -- `application/pdf` -- `image/jpeg` -- `image/jpg` -- `image/png` -5. Max size: `10 MB` (default) -6. Behavior: real upload to Cloud Storage. -7. Success `200`: -```json -{ - "fileUri": "gs://krow-workforce-dev-private/uploads//...", - "contentType": "application/pdf", - "size": 12345, - "bucket": "krow-workforce-dev-private", - "path": "uploads//...", - "requestId": "uuid" -} -``` -8. Errors: -- `UNAUTHENTICATED` -- `INVALID_FILE_TYPE` -- `FILE_TOO_LARGE` - -## 3.2 Create signed URL -1. Method and route: `POST /core/create-signed-url` -2. Request: -```json -{ - "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", - "expiresInSeconds": 300 -} -``` -3. Security checks: -- bucket must be allowed -- path must be owned by caller (`uploads//...`) -- object must exist -- `expiresInSeconds <= 900` -4. Success `200`: -```json -{ - "signedUrl": "https://storage.googleapis.com/...", - "expiresAt": "2026-02-24T15:22:28.105Z", - "requestId": "uuid" -} -``` -5. Errors: -- `VALIDATION_ERROR` -- `FORBIDDEN` -- `NOT_FOUND` - -## 3.3 Invoke model -1. Method and route: `POST /core/invoke-llm` -2. Request: -```json -{ - "prompt": "...", - "responseJsonSchema": {}, - "fileUrls": [] -} -``` -3. Behavior: -- real Vertex AI call -- model default: `gemini-2.0-flash-001` -- timeout default: `20 seconds` -4. Rate limit: -- `20 requests/minute` per user (default) -- when exceeded: `429 RATE_LIMITED` and `Retry-After` header -5. Success `200`: -```json -{ - "result": {}, - "model": "gemini-2.0-flash-001", - "latencyMs": 367, - "requestId": "uuid" -} -``` -6. Errors: -- `UNAUTHENTICATED` -- `VALIDATION_ERROR` -- `MODEL_TIMEOUT` -- `MODEL_FAILED` -- `RATE_LIMITED` - -## 3.4 Create verification job -1. Method and route: `POST /core/verifications` -2. Auth: required -3. Request: -```json -{ - "type": "attire", - "subjectType": "worker", - "subjectId": "worker_123", - "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", - "rules": {} -} -``` -4. Behavior: -- validates `fileUri` ownership -- requires file existence when `UPLOAD_MOCK=false` and `VERIFICATION_REQUIRE_FILE_EXISTS=true` -- enqueues async verification -5. Success `202`: -```json -{ - "verificationId": "ver_123", - "status": "PENDING", - "type": "attire", - "requestId": "uuid" -} -``` -6. Errors: -- `UNAUTHENTICATED` -- `VALIDATION_ERROR` -- `FORBIDDEN` -- `NOT_FOUND` - -## 3.5 Get verification status -1. Method and route: `GET /core/verifications/{verificationId}` -2. Auth: required -3. Success `200`: -```json -{ - "verificationId": "ver_123", - "status": "NEEDS_REVIEW", - "type": "attire", - "requestId": "uuid" -} -``` -4. Errors: -- `UNAUTHENTICATED` -- `FORBIDDEN` -- `NOT_FOUND` - -## 3.6 Review verification -1. Method and route: `POST /core/verifications/{verificationId}/review` -2. Auth: required -3. Request: -```json -{ - "decision": "APPROVED", - "note": "Manual review passed", - "reasonCode": "MANUAL_REVIEW" -} -``` -4. Success `200`: status becomes `APPROVED` or `REJECTED`. -5. Errors: -- `UNAUTHENTICATED` -- `VALIDATION_ERROR` -- `FORBIDDEN` -- `NOT_FOUND` - -## 3.7 Retry verification -1. Method and route: `POST /core/verifications/{verificationId}/retry` -2. Auth: required -3. Success `202`: status resets to `PENDING`. -4. Errors: -- `UNAUTHENTICATED` -- `FORBIDDEN` -- `NOT_FOUND` - -## 3.8 Health -1. Method and route: `GET /health` -2. Success `200`: -```json -{ - "ok": true, - "service": "krow-core-api", - "version": "dev", - "requestId": "uuid" -} -``` - -## 4) Locked defaults -1. Validation library: `zod`. -2. Validation schema location: `backend/core-api/src/contracts/`. -3. Buckets: -- `krow-workforce-dev-public` -- `krow-workforce-dev-private` -4. Model provider: Vertex AI Gemini. -5. Max signed URL expiry: `900` seconds. -6. LLM timeout: `20000` ms. -7. LLM rate limit: `20` requests/minute/user. -8. Verification access mode default: `authenticated`. -9. Verification file existence check default: enabled (`VERIFICATION_REQUIRE_FILE_EXISTS=true`). -10. Verification attire provider default in dev: `vertex` with model `gemini-2.0-flash-lite-001`. -11. Verification government/certification providers: external adapters via configured provider URL/token. +This file is kept only as a compatibility pointer. diff --git a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md index ef3e18dd..d5c2c6ba 100644 --- a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md +++ b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md @@ -1,375 +1,9 @@ -# M4 Core API Frontend Guide (Dev) +# Moved -Status: Active -Last updated: 2026-02-27 -Audience: Web and mobile frontend developers +The canonical Core API frontend doc now lives here: -## 1) Base URLs (dev) -1. Core API: `https://krow-core-api-e3g6witsvq-uc.a.run.app` +- `docs/BACKEND/API_GUIDES/V2/core-api.md` -## 2) Auth requirements -1. Send Firebase ID token on protected routes: -```http -Authorization: Bearer -``` -2. Health route is public: -- `GET /health` -3. All other routes require Firebase token. +Start from: -## 3) Standard error envelope -```json -{ - "code": "STRING_CODE", - "message": "Human readable message", - "details": {}, - "requestId": "uuid" -} -``` - -## 4) Core API endpoints - -## 4.1 Upload file -1. Route: `POST /core/upload-file` -2. Alias: `POST /uploadFile` -3. Content type: `multipart/form-data` -4. Form fields: -- `file` (required) -- `visibility` (optional: `public` or `private`, default `private`) -- `category` (optional) -5. Accepted file types: -- `application/pdf` -- `image/jpeg` -- `image/jpg` -- `image/png` -- `audio/webm` -- `audio/wav` -- `audio/x-wav` -- `audio/mpeg` -- `audio/mp3` -- `audio/mp4` -- `audio/m4a` -- `audio/aac` -- `audio/ogg` -- `audio/flac` -6. Max upload size: `10 MB` (default) -7. Current behavior: real upload to Cloud Storage (not mock) -8. Success `200` example: -```json -{ - "fileUri": "gs://krow-workforce-dev-private/uploads//173...", - "contentType": "application/pdf", - "size": 12345, - "bucket": "krow-workforce-dev-private", - "path": "uploads//173..._file.pdf", - "requestId": "uuid" -} -``` - -## 4.2 Create signed URL -1. Route: `POST /core/create-signed-url` -2. Alias: `POST /createSignedUrl` -3. Request body: -```json -{ - "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", - "expiresInSeconds": 300 -} -``` -4. Security checks: -- bucket must be allowed (`krow-workforce-dev-public` or `krow-workforce-dev-private`) -- path must be owned by caller (`uploads//...`) -- object must exist -- `expiresInSeconds` must be `<= 900` -5. Success `200` example: -```json -{ - "signedUrl": "https://storage.googleapis.com/...", - "expiresAt": "2026-02-24T15:22:28.105Z", - "requestId": "uuid" -} -``` -6. Typical errors: -- `400 VALIDATION_ERROR` (bad payload or expiry too high) -- `403 FORBIDDEN` (path not owned by caller) -- `404 NOT_FOUND` (object does not exist) - -## 4.3 Invoke model -1. Route: `POST /core/invoke-llm` -2. Alias: `POST /invokeLLM` -3. Request body: -```json -{ - "prompt": "Return JSON with keys summary and risk.", - "responseJsonSchema": { - "type": "object", - "properties": { - "summary": { "type": "string" }, - "risk": { "type": "string" } - }, - "required": ["summary", "risk"] - }, - "fileUrls": [] -} -``` -4. Current behavior: real Vertex model call (not mock) -- model: `gemini-2.0-flash-001` -- timeout: `20 seconds` -5. Rate limit: -- per-user `20 requests/minute` (default) -- on limit: `429 RATE_LIMITED` -- includes `Retry-After` header -6. Success `200` example: -```json -{ - "result": { "summary": "text", "risk": "Low" }, - "model": "gemini-2.0-flash-001", - "latencyMs": 367, - "requestId": "uuid" -} -``` - -## 4.4 Rapid order transcribe (audio to text) -1. Route: `POST /core/rapid-orders/transcribe` -2. Auth: required -3. Purpose: transcribe uploaded RAPID voice note into text for the RAPID input box. -4. Request body: -```json -{ - "audioFileUri": "gs://krow-workforce-dev-private/uploads//rapid-request.webm", - "locale": "en-US", - "promptHints": ["server", "urgent"] -} -``` -5. Security checks: -- `audioFileUri` must be in allowed bucket -- `audioFileUri` path must be owned by caller (`uploads//...`) -- file existence is required in non-mock upload mode -6. Success `200` example: -```json -{ - "transcript": "Need 2 servers ASAP for 4 hours.", - "confidence": 0.87, - "language": "en-US", - "warnings": [], - "model": "gemini-2.0-flash-001", - "latencyMs": 412, - "requestId": "uuid" -} -``` -7. Typical errors: -- `400 VALIDATION_ERROR` (invalid payload) -- `401 UNAUTHENTICATED` (missing/invalid bearer token) -- `403 FORBIDDEN` (audio path not owned by caller) -- `429 RATE_LIMITED` (model quota per user) -- `502 MODEL_FAILED` (upstream model output/availability) - -## 4.5 Rapid order parse (text to structured draft) -1. Route: `POST /core/rapid-orders/parse` -2. Auth: required -3. Purpose: convert RAPID text into structured one-time order draft JSON for form prefill. -4. Request body: -```json -{ - "text": "Need 2 servers ASAP for 4 hours", - "locale": "en-US", - "timezone": "America/New_York", - "now": "2026-02-27T12:00:00.000Z" -} -``` -5. Success `200` example: -```json -{ - "parsed": { - "orderType": "ONE_TIME", - "isRapid": true, - "positions": [ - { "role": "server", "count": 2 } - ], - "startAt": "2026-02-27T12:00:00.000Z", - "endAt": null, - "durationMinutes": 240, - "locationHint": null, - "notes": null, - "sourceText": "Need 2 servers ASAP for 4 hours" - }, - "missingFields": [], - "warnings": [], - "confidence": { - "overall": 0.72, - "fields": { - "positions": 0.86, - "startAt": 0.9, - "durationMinutes": 0.88 - } - }, - "model": "gemini-2.0-flash-001", - "latencyMs": 531, - "requestId": "uuid" -} -``` -6. Contract notes: -- unknown request keys are rejected (`400 VALIDATION_ERROR`) -- when information is missing/ambiguous, backend returns `missingFields` and `warnings` -- frontend should use output to prefill one-time order and request user confirmation where needed - -## 4.6 Create verification job -1. Route: `POST /core/verifications` -2. Auth: required -3. Purpose: enqueue an async verification job for an uploaded file. -4. Request body: -```json -{ - "type": "attire", - "subjectType": "worker", - "subjectId": "", - "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", - "rules": { - "dressCode": "black shoes" - } -} -``` -5. Success `202` example: -```json -{ - "verificationId": "ver_123", - "status": "PENDING", - "type": "attire", - "requestId": "uuid" -} -``` -6. Current machine processing behavior in dev: -- `attire`: live vision check using Vertex Gemini Flash Lite model. -- `government_id`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). -- `certification`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). - -## 4.7 Get verification status -1. Route: `GET /core/verifications/{verificationId}` -2. Auth: required -3. Purpose: polling status from frontend. -4. Success `200` example: -```json -{ - "verificationId": "ver_123", - "status": "NEEDS_REVIEW", - "type": "attire", - "review": null, - "requestId": "uuid" -} -``` - -## 4.8 Review verification -1. Route: `POST /core/verifications/{verificationId}/review` -2. Auth: required -3. Purpose: final human decision for the verification. -4. Request body: -```json -{ - "decision": "APPROVED", - "note": "Manual review passed", - "reasonCode": "MANUAL_REVIEW" -} -``` -5. Success `200` example: -```json -{ - "verificationId": "ver_123", - "status": "APPROVED", - "review": { - "decision": "APPROVED", - "reviewedBy": "" - }, - "requestId": "uuid" -} -``` - -## 4.9 Retry verification -1. Route: `POST /core/verifications/{verificationId}/retry` -2. Auth: required -3. Purpose: requeue verification to run again. -4. Success `202` example: status resets to `PENDING`. - -## 5) Frontend fetch examples (web) - -## 5.1 Signed URL request -```ts -const token = await firebaseAuth.currentUser?.getIdToken(); -const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/create-signed-url', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - fileUri: 'gs://krow-workforce-dev-private/uploads//file.pdf', - expiresInSeconds: 300, - }), -}); -const data = await res.json(); -``` - -## 5.2 Model request -```ts -const token = await firebaseAuth.currentUser?.getIdToken(); -const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/invoke-llm', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - prompt: 'Return JSON with status.', - responseJsonSchema: { - type: 'object', - properties: { status: { type: 'string' } }, - required: ['status'], - }, - }), -}); -const data = await res.json(); -``` - -## 5.3 Rapid audio transcribe request -```ts -const token = await firebaseAuth.currentUser?.getIdToken(); -const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/rapid-orders/transcribe', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - audioFileUri: 'gs://krow-workforce-dev-private/uploads//rapid-request.webm', - locale: 'en-US', - promptHints: ['server', 'urgent'], - }), -}); -const data = await res.json(); -``` - -## 5.4 Rapid text parse request -```ts -const token = await firebaseAuth.currentUser?.getIdToken(); -const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/rapid-orders/parse', { - method: 'POST', - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - text: 'Need 2 servers ASAP for 4 hours', - locale: 'en-US', - timezone: 'America/New_York', - }), -}); -const data = await res.json(); -``` - -## 6) Notes for frontend team -1. Use canonical `/core/*` routes for new work. -2. Aliases exist only for migration compatibility. -3. `requestId` in responses should be logged client-side for debugging. -4. For 429 on model route, retry with exponential backoff and respect `Retry-After`. -5. Verification routes are now available in dev under `/core/verifications*`. -6. Current verification processing is async and returns machine statuses first (`PENDING`, `PROCESSING`, `NEEDS_REVIEW`, etc.). -7. Full verification design and policy details: - `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md`. +- `docs/BACKEND/API_GUIDES/V2/README.md` diff --git a/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md b/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md index 2f81014b..31d7a425 100644 --- a/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md +++ b/docs/MILESTONES/M4/planning/m4-target-schema-blueprint.md @@ -178,11 +178,17 @@ Tables: 3. `workforce` 4. `applications` 5. `assignments` +6. `staff_reviews` +7. `staff_favorites` Rules: 1. One active workforce relation per `(vendor_id, staff_id)`. 2. One application per `(shift_id, role_id, staff_id)` unless versioned intentionally. 3. Assignment state transitions only through command APIs. +4. Business quality signals are relational: + - `staff_reviews` stores rating and review text from businesses, + - `staff_favorites` stores reusable staffing preferences, + - aggregate rating is materialized on `staffs`. ## 4.5 Compliance and Verification Tables: @@ -222,19 +228,22 @@ Rules: ## 4.9 Attendance, Timesheets, and Offense Governance Tables: -1. `attendance_events` (append-only: clock-in/out, source, correction metadata) -2. `attendance_sessions` (derived work session per assignment) -3. `timesheets` (approval-ready payroll snapshot) -4. `timesheet_adjustments` (manual edits with reason and actor) -5. `offense_policies` (tenant/business scoped policy set) -6. `offense_rules` (threshold ladder and consequence) -7. `offense_events` (actual violation events) -8. `enforcement_actions` (warning, suspension, disable, block) +1. `clock_points` (approved tap and geo validation points per business or venue) +2. `attendance_events` (append-only: clock-in/out, source, NFC, geo, correction metadata) +3. `attendance_sessions` (derived work session per assignment) +4. `timesheets` (approval-ready payroll snapshot) +5. `timesheet_adjustments` (manual edits with reason and actor) +6. `offense_policies` (tenant/business scoped policy set) +7. `offense_rules` (threshold ladder and consequence) +8. `offense_events` (actual violation events) +9. `enforcement_actions` (warning, suspension, disable, block) Rules: 1. Attendance corrections are additive events, not destructive overwrites. -2. Offense consequences are computed from policy + history and persisted as explicit actions. -3. Manual overrides require actor, reason, and timestamp in audit trail. +2. NFC and geo validation happens against `clock_points`, not hardcoded client logic. +3. Rejected attendance attempts are still logged as events for audit. +4. Offense consequences are computed from policy + history and persisted as explicit actions. +5. Manual overrides require actor, reason, and timestamp in audit trail. ## 4.10 Stakeholder Network Extensibility Tables: diff --git a/docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md b/docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md index ebfb3dc8..71ebefee 100644 --- a/docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md +++ b/docs/MILESTONES/M4/planning/m4-target-schema-models-and-keys.md @@ -96,6 +96,8 @@ erDiagram | `shift_managers` | `id` | `shift_id -> shifts.id`, `team_member_id -> team_members.id` | `(shift_id, team_member_id)` | | `applications` | `id` | `tenant_id -> tenants.id`, `shift_id -> shifts.id`, `role_id -> roles.id`, `staff_id -> staffs.id` | `(shift_id, role_id, staff_id)` | | `assignments` | `id` | `tenant_id -> tenants.id`, `shift_role_id -> shift_roles.id`, `workforce_id -> workforce.id` | `(shift_role_id, workforce_id)` active | +| `staff_reviews` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id`, `staff_id -> staffs.id`, `assignment_id -> assignments.id` | `(business_id, assignment_id, staff_id)` | +| `staff_favorites` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id`, `staff_id -> staffs.id` | `(business_id, staff_id)` | ### 4.2 Diagram @@ -122,6 +124,11 @@ erDiagram STAFFS ||--o{ APPLICATIONS : applies SHIFT_ROLES ||--o{ ASSIGNMENTS : allocates WORKFORCE ||--o{ ASSIGNMENTS : executes + BUSINESSES ||--o{ STAFF_REVIEWS : rates + STAFFS ||--o{ STAFF_REVIEWS : receives + ASSIGNMENTS ||--o{ STAFF_REVIEWS : references + BUSINESSES ||--o{ STAFF_FAVORITES : favorites + STAFFS ||--o{ STAFF_FAVORITES : selected ``` ``` @@ -131,7 +138,8 @@ erDiagram | Model | Primary key | Foreign keys | Important unique keys | |---|---|---|---| -| `attendance_events` | `id` | `tenant_id -> tenants.id`, `assignment_id -> assignments.id` | `(assignment_id, source_event_id)` | +| `clock_points` | `id` | `tenant_id -> tenants.id`, `business_id -> businesses.id` | `(tenant_id, nfc_tag_uid)` nullable | +| `attendance_events` | `id` | `tenant_id -> tenants.id`, `assignment_id -> assignments.id`, `clock_point_id -> clock_points.id` | append-only event log | | `attendance_sessions` | `id` | `tenant_id -> tenants.id`, `assignment_id -> assignments.id` | one open session per assignment | | `timesheets` | `id` | `tenant_id -> tenants.id`, `assignment_id -> assignments.id`, `staff_id -> staffs.id` | `(assignment_id)` | | `timesheet_adjustments` | `id` | `timesheet_id -> timesheets.id`, `actor_user_id -> users.id` | - | @@ -144,6 +152,8 @@ erDiagram ```mermaid erDiagram + BUSINESSES ||--o{ CLOCK_POINTS : defines + CLOCK_POINTS ||--o{ ATTENDANCE_EVENTS : validates ASSIGNMENTS ||--o{ ATTENDANCE_EVENTS : emits ASSIGNMENTS ||--o{ ATTENDANCE_SESSIONS : opens ASSIGNMENTS ||--o{ TIMESHEETS : settles diff --git a/docs/MILESTONES/M4/planning/m4-v2-frontend-migration-guide.md b/docs/MILESTONES/M4/planning/m4-v2-frontend-migration-guide.md new file mode 100644 index 00000000..87923395 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-v2-frontend-migration-guide.md @@ -0,0 +1,10 @@ +# Moved + +The canonical frontend-facing v2 backend docs now live here: + +- `docs/BACKEND/API_GUIDES/V2/README.md` +- `docs/BACKEND/API_GUIDES/V2/core-api.md` +- `docs/BACKEND/API_GUIDES/V2/command-api.md` +- `docs/BACKEND/API_GUIDES/V2/query-api.md` + +This file is kept only as a compatibility pointer. diff --git a/makefiles/backend.mk b/makefiles/backend.mk index 5ee113c0..e940d293 100644 --- a/makefiles/backend.mk +++ b/makefiles/backend.mk @@ -36,7 +36,64 @@ BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS ?= 8000 BACKEND_MAX_SIGNED_URL_SECONDS ?= 900 BACKEND_LLM_RATE_LIMIT_PER_MINUTE ?= 20 -.PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core +BACKEND_V2_ARTIFACT_REPO ?= krow-backend-v2 +BACKEND_V2_CORE_SERVICE_NAME ?= krow-core-api-v2 +BACKEND_V2_COMMAND_SERVICE_NAME ?= krow-command-api-v2 +BACKEND_V2_QUERY_SERVICE_NAME ?= krow-query-api-v2 +BACKEND_V2_UNIFIED_SERVICE_NAME ?= krow-api-v2 +BACKEND_V2_NOTIFICATION_JOB_NAME ?= krow-notification-dispatcher-v2 +BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME ?= krow-notification-worker-v2 +BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME ?= krow-notification-dispatch-v2 +BACKEND_V2_RUNTIME_SA_NAME ?= krow-backend-v2-runtime +BACKEND_V2_RUNTIME_SA_EMAIL := $(BACKEND_V2_RUNTIME_SA_NAME)@$(GCP_PROJECT_ID).iam.gserviceaccount.com +BACKEND_V2_SCHEDULER_SA_NAME ?= krow-backend-v2-scheduler +BACKEND_V2_SCHEDULER_SA_EMAIL := $(BACKEND_V2_SCHEDULER_SA_NAME)@$(GCP_PROJECT_ID).iam.gserviceaccount.com + +BACKEND_V2_CORE_DIR ?= backend/core-api +BACKEND_V2_COMMAND_DIR ?= backend/command-api +BACKEND_V2_QUERY_DIR ?= backend/query-api +BACKEND_V2_UNIFIED_DIR ?= backend/unified-api + +BACKEND_V2_SQL_INSTANCE ?= krow-sql-v2 +BACKEND_V2_SQL_DATABASE ?= krow_v2_db +BACKEND_V2_SQL_APP_USER ?= krow_v2_app +BACKEND_V2_SQL_PASSWORD_SECRET ?= krow-v2-sql-app-password +BACKEND_V2_SQL_CONNECTION_NAME ?= $(GCP_PROJECT_ID):$(BACKEND_REGION):$(BACKEND_V2_SQL_INSTANCE) +BACKEND_V2_SQL_TIER ?= $(SQL_TIER) + +BACKEND_V2_DEV_PUBLIC_BUCKET ?= krow-workforce-dev-v2-public +BACKEND_V2_DEV_PRIVATE_BUCKET ?= krow-workforce-dev-v2-private +BACKEND_V2_STAGING_PUBLIC_BUCKET ?= krow-workforce-staging-v2-public +BACKEND_V2_STAGING_PRIVATE_BUCKET ?= krow-workforce-staging-v2-private + +ifeq ($(ENV),staging) + BACKEND_V2_PUBLIC_BUCKET := $(BACKEND_V2_STAGING_PUBLIC_BUCKET) + BACKEND_V2_PRIVATE_BUCKET := $(BACKEND_V2_STAGING_PRIVATE_BUCKET) + BACKEND_V2_RUN_AUTH_FLAG := --no-allow-unauthenticated +else + BACKEND_V2_PUBLIC_BUCKET := $(BACKEND_V2_DEV_PUBLIC_BUCKET) + BACKEND_V2_PRIVATE_BUCKET := $(BACKEND_V2_DEV_PRIVATE_BUCKET) + BACKEND_V2_RUN_AUTH_FLAG := --allow-unauthenticated +endif + +BACKEND_V2_CORE_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/core-api-v2:latest +BACKEND_V2_COMMAND_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/command-api-v2:latest +BACKEND_V2_QUERY_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/query-api-v2:latest +BACKEND_V2_UNIFIED_IMAGE ?= $(BACKEND_REGION)-docker.pkg.dev/$(GCP_PROJECT_ID)/$(BACKEND_V2_ARTIFACT_REPO)/unified-api-v2:latest +BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET ?= firebase-web-api-key +BACKEND_V2_NOTIFICATION_BATCH_LIMIT ?= 50 +BACKEND_V2_PUSH_DELIVERY_MODE ?= live +BACKEND_V2_SHIFT_REMINDERS_ENABLED ?= true +BACKEND_V2_SHIFT_REMINDER_LEAD_MINUTES ?= 60,15 +BACKEND_V2_SHIFT_REMINDER_WINDOW_MINUTES ?= 5 +BACKEND_V2_NOTIFICATION_SCHEDULE ?= * * * * * +BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE ?= UTC +BACKEND_V2_NFC_ENFORCE_PROOF_NONCE ?= false +BACKEND_V2_NFC_ENFORCE_DEVICE_ID ?= false +BACKEND_V2_NFC_ENFORCE_ATTESTATION ?= false +BACKEND_V2_NFC_PROOF_MAX_AGE_SECONDS ?= 120 + +.PHONY: backend-help backend-enable-apis backend-bootstrap-dev backend-migrate-idempotency backend-deploy-core backend-deploy-commands backend-deploy-workers backend-smoke-core backend-smoke-commands backend-logs-core backend-bootstrap-v2-dev backend-deploy-core-v2 backend-deploy-commands-v2 backend-deploy-query-v2 backend-deploy-unified-v2 backend-deploy-notification-worker-v2 backend-configure-notification-scheduler-v2 backend-run-notification-worker-v2 backend-smoke-notification-worker-v2 backend-deploy-notification-job-v2 backend-run-notification-job-v2 backend-smoke-core-v2 backend-smoke-commands-v2 backend-smoke-query-v2 backend-smoke-unified-v2 backend-logs-core-v2 backend-v2-migrate-idempotency backend-v2-migrate-schema backend-help: @echo "--> Backend Foundation Commands" @@ -49,6 +106,24 @@ backend-help: @echo " make backend-smoke-core [ENV=dev] Smoke test core /health" @echo " make backend-smoke-commands [ENV=dev] Smoke test commands /health" @echo " make backend-logs-core [ENV=dev] Read core service logs" + @echo "" + @echo "--> Backend Foundation Commands (isolated v2 stack)" + @echo " make backend-bootstrap-v2-dev [ENV=dev] Bootstrap isolated v2 resources and SQL instance" + @echo " make backend-deploy-core-v2 [ENV=dev] Build + deploy core API v2 service" + @echo " make backend-deploy-commands-v2 [ENV=dev] Build + deploy command API v2 service" + @echo " make backend-deploy-query-v2 [ENV=dev] Build + deploy query API v2 service" + @echo " make backend-deploy-unified-v2 [ENV=dev] Build + deploy unified API v2 gateway" + @echo " make backend-deploy-notification-worker-v2 Deploy private notification worker v2 service" + @echo " make backend-configure-notification-scheduler-v2 Configure Cloud Scheduler for notification worker" + @echo " make backend-run-notification-worker-v2 Invoke notification worker v2 once" + @echo " make backend-smoke-notification-worker-v2 Smoke test private notification worker v2" + @echo " make backend-v2-migrate-schema Apply v2 domain schema against krow-sql-v2" + @echo " make backend-v2-migrate-idempotency Apply command idempotency migration against v2 DB" + @echo " make backend-smoke-core-v2 [ENV=dev] Smoke test core API v2 /health" + @echo " make backend-smoke-commands-v2 [ENV=dev] Smoke test command API v2 /health" + @echo " make backend-smoke-query-v2 [ENV=dev] Smoke test query API v2 /health" + @echo " make backend-smoke-unified-v2 [ENV=dev] Smoke test unified API v2 /health" + @echo " make backend-logs-core-v2 [ENV=dev] Read core API v2 logs" backend-enable-apis: @echo "--> Enabling backend APIs on project [$(GCP_PROJECT_ID)]..." @@ -64,7 +139,8 @@ backend-enable-apis: iam.googleapis.com \ iamcredentials.googleapis.com \ serviceusage.googleapis.com \ - firebase.googleapis.com; do \ + firebase.googleapis.com \ + cloudscheduler.googleapis.com; do \ echo " - $$api"; \ gcloud services enable $$api --project=$(GCP_PROJECT_ID); \ done @@ -190,3 +266,339 @@ backend-logs-core: --region=$(BACKEND_REGION) \ --project=$(GCP_PROJECT_ID) \ --limit=$(BACKEND_LOG_LIMIT) + +backend-bootstrap-v2-dev: backend-enable-apis + @echo "--> Bootstrapping isolated backend v2 foundation for [$(ENV)] on project [$(GCP_PROJECT_ID)]..." + @echo "--> Ensuring Artifact Registry repo [$(BACKEND_V2_ARTIFACT_REPO)] exists..." + @if ! gcloud artifacts repositories describe $(BACKEND_V2_ARTIFACT_REPO) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud artifacts repositories create $(BACKEND_V2_ARTIFACT_REPO) \ + --repository-format=docker \ + --location=$(BACKEND_REGION) \ + --description="KROW backend v2 services" \ + --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Artifact Registry repo already exists."; \ + fi + @echo "--> Ensuring v2 runtime service account [$(BACKEND_V2_RUNTIME_SA_NAME)] exists..." + @if ! gcloud iam service-accounts describe $(BACKEND_V2_RUNTIME_SA_EMAIL) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud iam service-accounts create $(BACKEND_V2_RUNTIME_SA_NAME) \ + --display-name="KROW Backend Runtime V2" \ + --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Runtime service account already exists."; \ + fi + @echo "--> Ensuring v2 scheduler service account [$(BACKEND_V2_SCHEDULER_SA_NAME)] exists..." + @if ! gcloud iam service-accounts describe $(BACKEND_V2_SCHEDULER_SA_EMAIL) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud iam service-accounts create $(BACKEND_V2_SCHEDULER_SA_NAME) \ + --display-name="KROW Backend Scheduler V2" \ + --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Scheduler service account already exists."; \ + fi + @echo "--> Ensuring v2 runtime service account IAM roles..." + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \ + --role="roles/storage.objectAdmin" \ + --quiet >/dev/null + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \ + --role="roles/aiplatform.user" \ + --quiet >/dev/null + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \ + --role="roles/cloudsql.client" \ + --quiet >/dev/null + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \ + --role="roles/secretmanager.secretAccessor" \ + --quiet >/dev/null + @gcloud projects add-iam-policy-binding $(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \ + --role="roles/firebasecloudmessaging.admin" \ + --quiet >/dev/null + @gcloud iam service-accounts add-iam-policy-binding $(BACKEND_V2_RUNTIME_SA_EMAIL) \ + --member="serviceAccount:$(BACKEND_V2_RUNTIME_SA_EMAIL)" \ + --role="roles/iam.serviceAccountTokenCreator" \ + --project=$(GCP_PROJECT_ID) \ + --quiet >/dev/null + @echo "--> Ensuring v2 storage buckets exist..." + @if ! gcloud storage buckets describe gs://$(BACKEND_V2_PUBLIC_BUCKET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud storage buckets create gs://$(BACKEND_V2_PUBLIC_BUCKET) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Public bucket already exists: $(BACKEND_V2_PUBLIC_BUCKET)"; \ + fi + @if ! gcloud storage buckets describe gs://$(BACKEND_V2_PRIVATE_BUCKET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud storage buckets create gs://$(BACKEND_V2_PRIVATE_BUCKET) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Private bucket already exists: $(BACKEND_V2_PRIVATE_BUCKET)"; \ + fi + @echo "--> Ensuring v2 Cloud SQL instance [$(BACKEND_V2_SQL_INSTANCE)] exists..." + @if ! gcloud sql instances describe $(BACKEND_V2_SQL_INSTANCE) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud sql instances create $(BACKEND_V2_SQL_INSTANCE) \ + --database-version=POSTGRES_15 \ + --tier=$(BACKEND_V2_SQL_TIER) \ + --region=$(BACKEND_REGION) \ + --storage-size=10 \ + --storage-auto-increase \ + --availability-type=zonal \ + --backup-start-time=03:00 \ + --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Cloud SQL instance already exists: $(BACKEND_V2_SQL_INSTANCE)"; \ + fi + @echo "--> Ensuring v2 Cloud SQL database [$(BACKEND_V2_SQL_DATABASE)] exists..." + @if ! gcloud sql databases describe $(BACKEND_V2_SQL_DATABASE) --instance=$(BACKEND_V2_SQL_INSTANCE) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud sql databases create $(BACKEND_V2_SQL_DATABASE) --instance=$(BACKEND_V2_SQL_INSTANCE) --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Cloud SQL database already exists: $(BACKEND_V2_SQL_DATABASE)"; \ + fi + @echo "--> Ensuring v2 SQL application password secret [$(BACKEND_V2_SQL_PASSWORD_SECRET)] exists..." + @if ! gcloud secrets describe $(BACKEND_V2_SQL_PASSWORD_SECRET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + PASSWORD=$$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 32); \ + printf "%s" "$$PASSWORD" | gcloud secrets create $(BACKEND_V2_SQL_PASSWORD_SECRET) \ + --replication-policy=automatic \ + --data-file=- \ + --project=$(GCP_PROJECT_ID); \ + else \ + echo " - Secret already exists: $(BACKEND_V2_SQL_PASSWORD_SECRET)"; \ + fi + @echo "--> Ensuring v2 SQL application user [$(BACKEND_V2_SQL_APP_USER)] exists and matches the current secret..." + @DB_PASSWORD=$$(gcloud secrets versions access latest --secret=$(BACKEND_V2_SQL_PASSWORD_SECRET) --project=$(GCP_PROJECT_ID)); \ + if gcloud sql users list --instance=$(BACKEND_V2_SQL_INSTANCE) --project=$(GCP_PROJECT_ID) --format='value(name)' | grep -qx "$(BACKEND_V2_SQL_APP_USER)"; then \ + gcloud sql users set-password $(BACKEND_V2_SQL_APP_USER) \ + --instance=$(BACKEND_V2_SQL_INSTANCE) \ + --password="$$DB_PASSWORD" \ + --project=$(GCP_PROJECT_ID) >/dev/null; \ + else \ + gcloud sql users create $(BACKEND_V2_SQL_APP_USER) \ + --instance=$(BACKEND_V2_SQL_INSTANCE) \ + --password="$$DB_PASSWORD" \ + --project=$(GCP_PROJECT_ID) >/dev/null; \ + fi + @echo "✅ Backend v2 foundation bootstrap complete for [$(ENV)]." + +backend-deploy-core-v2: + @echo "--> Deploying core backend v2 service [$(BACKEND_V2_CORE_SERVICE_NAME)] to [$(ENV)]..." + @test -d $(BACKEND_V2_CORE_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_CORE_DIR)" && exit 1) + @test -f $(BACKEND_V2_CORE_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_CORE_DIR)/Dockerfile" && exit 1) + @gcloud builds submit $(BACKEND_V2_CORE_DIR) --tag $(BACKEND_V2_CORE_IMAGE) --project=$(GCP_PROJECT_ID) + @EXTRA_ENV="APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),UPLOAD_MOCK=false,SIGNED_URL_MOCK=false,LLM_MOCK=false,LLM_LOCATION=$(BACKEND_REGION),LLM_MODEL=$(BACKEND_LLM_MODEL),LLM_TIMEOUT_MS=20000,MAX_SIGNED_URL_SECONDS=$(BACKEND_MAX_SIGNED_URL_SECONDS),LLM_RATE_LIMIT_PER_MINUTE=$(BACKEND_LLM_RATE_LIMIT_PER_MINUTE),VERIFICATION_STORE=sql,VERIFICATION_ACCESS_MODE=authenticated,VERIFICATION_REQUIRE_FILE_EXISTS=true,VERIFICATION_ATTIRE_PROVIDER=vertex,VERIFICATION_ATTIRE_MODEL=$(BACKEND_VERIFICATION_ATTIRE_MODEL),VERIFICATION_PROVIDER_TIMEOUT_MS=$(BACKEND_VERIFICATION_PROVIDER_TIMEOUT_MS),INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER)"; \ + gcloud run deploy $(BACKEND_V2_CORE_SERVICE_NAME) \ + --image=$(BACKEND_V2_CORE_IMAGE) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \ + --set-env-vars=$$EXTRA_ENV \ + --set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \ + --set-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \ + $(BACKEND_V2_RUN_AUTH_FLAG) + @echo "✅ Core backend v2 service deployed." + +backend-deploy-commands-v2: + @echo "--> Deploying command backend v2 service [$(BACKEND_V2_COMMAND_SERVICE_NAME)] to [$(ENV)]..." + @test -d $(BACKEND_V2_COMMAND_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_COMMAND_DIR)" && exit 1) + @test -f $(BACKEND_V2_COMMAND_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_COMMAND_DIR)/Dockerfile" && exit 1) + @gcloud builds submit $(BACKEND_V2_COMMAND_DIR) --tag $(BACKEND_V2_COMMAND_IMAGE) --project=$(GCP_PROJECT_ID) + @EXTRA_ENV="APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),IDEMPOTENCY_STORE=sql,INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER),NOTIFICATION_BATCH_LIMIT=$(BACKEND_V2_NOTIFICATION_BATCH_LIMIT),PUSH_DELIVERY_MODE=$(BACKEND_V2_PUSH_DELIVERY_MODE),SHIFT_REMINDERS_ENABLED=$(BACKEND_V2_SHIFT_REMINDERS_ENABLED),SHIFT_REMINDER_WINDOW_MINUTES=$(BACKEND_V2_SHIFT_REMINDER_WINDOW_MINUTES),NFC_ENFORCE_PROOF_NONCE=$(BACKEND_V2_NFC_ENFORCE_PROOF_NONCE),NFC_ENFORCE_DEVICE_ID=$(BACKEND_V2_NFC_ENFORCE_DEVICE_ID),NFC_ENFORCE_ATTESTATION=$(BACKEND_V2_NFC_ENFORCE_ATTESTATION),NFC_PROOF_MAX_AGE_SECONDS=$(BACKEND_V2_NFC_PROOF_MAX_AGE_SECONDS)"; \ + gcloud run deploy $(BACKEND_V2_COMMAND_SERVICE_NAME) \ + --image=$(BACKEND_V2_COMMAND_IMAGE) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \ + --set-env-vars=$$EXTRA_ENV \ + --set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \ + --set-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \ + $(BACKEND_V2_RUN_AUTH_FLAG) + @echo "✅ Command backend v2 service deployed." + +backend-deploy-notification-worker-v2: + @echo "--> Deploying private notification worker v2 service [$(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME)]..." + @test -d $(BACKEND_V2_COMMAND_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_COMMAND_DIR)" && exit 1) + @test -f $(BACKEND_V2_COMMAND_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_COMMAND_DIR)/Dockerfile" && exit 1) + @gcloud builds submit $(BACKEND_V2_COMMAND_DIR) --tag $(BACKEND_V2_COMMAND_IMAGE) --project=$(GCP_PROJECT_ID) + @EXTRA_ENV="APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),PUBLIC_BUCKET=$(BACKEND_V2_PUBLIC_BUCKET),PRIVATE_BUCKET=$(BACKEND_V2_PRIVATE_BUCKET),INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER),NOTIFICATION_BATCH_LIMIT=$(BACKEND_V2_NOTIFICATION_BATCH_LIMIT),PUSH_DELIVERY_MODE=$(BACKEND_V2_PUSH_DELIVERY_MODE),SHIFT_REMINDERS_ENABLED=$(BACKEND_V2_SHIFT_REMINDERS_ENABLED),SHIFT_REMINDER_WINDOW_MINUTES=$(BACKEND_V2_SHIFT_REMINDER_WINDOW_MINUTES),NFC_ENFORCE_PROOF_NONCE=$(BACKEND_V2_NFC_ENFORCE_PROOF_NONCE),NFC_ENFORCE_DEVICE_ID=$(BACKEND_V2_NFC_ENFORCE_DEVICE_ID),NFC_ENFORCE_ATTESTATION=$(BACKEND_V2_NFC_ENFORCE_ATTESTATION),NFC_PROOF_MAX_AGE_SECONDS=$(BACKEND_V2_NFC_PROOF_MAX_AGE_SECONDS)"; \ + gcloud run deploy $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME) \ + --image=$(BACKEND_V2_COMMAND_IMAGE) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \ + --command=node \ + --args=src/worker-server.js \ + --set-env-vars=$$EXTRA_ENV \ + --set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \ + --set-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \ + --concurrency=1 \ + --max-instances=1 \ + --no-allow-unauthenticated + @if ! gcloud iam service-accounts describe $(BACKEND_V2_SCHEDULER_SA_EMAIL) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud iam service-accounts create $(BACKEND_V2_SCHEDULER_SA_NAME) \ + --display-name="KROW Backend Scheduler V2" \ + --project=$(GCP_PROJECT_ID); \ + fi + @gcloud run services add-iam-policy-binding $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --member="serviceAccount:$(BACKEND_V2_SCHEDULER_SA_EMAIL)" \ + --role="roles/run.invoker" \ + --quiet >/dev/null + @echo "✅ Notification worker v2 service deployed." + +backend-configure-notification-scheduler-v2: + @echo "--> Configuring notification scheduler [$(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME)]..." + @gcloud services enable cloudscheduler.googleapis.com --project=$(GCP_PROJECT_ID) >/dev/null + @URL=$$(gcloud run services describe $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$URL" ]; then \ + echo "❌ Could not resolve URL for service $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME)"; \ + exit 1; \ + fi; \ + if gcloud scheduler jobs describe $(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + gcloud scheduler jobs update http $(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME) \ + --location=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --schedule='$(BACKEND_V2_NOTIFICATION_SCHEDULE)' \ + --time-zone='$(BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE)' \ + --uri="$$URL/tasks/dispatch-notifications" \ + --http-method=POST \ + --headers=Content-Type=application/json \ + --message-body='{}' \ + --oidc-service-account-email=$(BACKEND_V2_SCHEDULER_SA_EMAIL) \ + --oidc-token-audience="$$URL"; \ + else \ + gcloud scheduler jobs create http $(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME) \ + --location=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --schedule='$(BACKEND_V2_NOTIFICATION_SCHEDULE)' \ + --time-zone='$(BACKEND_V2_NOTIFICATION_SCHEDULER_TIME_ZONE)' \ + --uri="$$URL/tasks/dispatch-notifications" \ + --http-method=POST \ + --headers=Content-Type=application/json \ + --message-body='{}' \ + --oidc-service-account-email=$(BACKEND_V2_SCHEDULER_SA_EMAIL) \ + --oidc-token-audience="$$URL"; \ + fi + @echo "✅ Notification scheduler configured." + +backend-smoke-notification-worker-v2: + @echo "--> Running notification worker smoke check..." + @URL=$$(gcloud run services describe $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$URL" ]; then \ + echo "❌ Could not resolve URL for service $(BACKEND_V2_NOTIFICATION_WORKER_SERVICE_NAME)"; \ + exit 1; \ + fi; \ + gcloud scheduler jobs describe $(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME) --location=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) >/dev/null && \ + echo "✅ Notification worker smoke check passed: $$URL (scheduler wired)" + +backend-run-notification-worker-v2: + @echo "--> Triggering notification worker via scheduler job [$(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME)]..." + @gcloud scheduler jobs run $(BACKEND_V2_NOTIFICATION_SCHEDULER_JOB_NAME) \ + --location=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) >/dev/null + @echo "✅ Notification worker v2 invocation requested through Cloud Scheduler." + +backend-deploy-notification-job-v2: backend-deploy-notification-worker-v2 + @echo "⚠️ Cloud Run Job dispatcher is deprecated. Using private worker service instead." + +backend-run-notification-job-v2: backend-run-notification-worker-v2 + @echo "⚠️ Cloud Run Job dispatcher is deprecated. Using private worker service instead." + +backend-deploy-query-v2: + @echo "--> Deploying query backend v2 service [$(BACKEND_V2_QUERY_SERVICE_NAME)] to [$(ENV)]..." + @test -d $(BACKEND_V2_QUERY_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_QUERY_DIR)" && exit 1) + @test -f $(BACKEND_V2_QUERY_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_QUERY_DIR)/Dockerfile" && exit 1) + @gcloud builds submit $(BACKEND_V2_QUERY_DIR) --tag $(BACKEND_V2_QUERY_IMAGE) --project=$(GCP_PROJECT_ID) + @gcloud run deploy $(BACKEND_V2_QUERY_SERVICE_NAME) \ + --image=$(BACKEND_V2_QUERY_IMAGE) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \ + --set-env-vars=APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER) \ + --set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest \ + --add-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \ + $(BACKEND_V2_RUN_AUTH_FLAG) + @echo "✅ Query backend v2 service deployed." + +backend-deploy-unified-v2: + @echo "--> Deploying unified backend v2 gateway [$(BACKEND_V2_UNIFIED_SERVICE_NAME)] to [$(ENV)]..." + @test -d $(BACKEND_V2_UNIFIED_DIR) || (echo "❌ Missing directory: $(BACKEND_V2_UNIFIED_DIR)" && exit 1) + @test -f $(BACKEND_V2_UNIFIED_DIR)/Dockerfile || (echo "❌ Missing Dockerfile: $(BACKEND_V2_UNIFIED_DIR)/Dockerfile" && exit 1) + @if ! gcloud secrets describe $(BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET) --project=$(GCP_PROJECT_ID) >/dev/null 2>&1; then \ + echo "❌ Missing secret: $(BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET)"; \ + exit 1; \ + fi + @CORE_URL=$$(gcloud run services describe $(BACKEND_V2_CORE_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + COMMAND_URL=$$(gcloud run services describe $(BACKEND_V2_COMMAND_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + QUERY_URL=$$(gcloud run services describe $(BACKEND_V2_QUERY_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$CORE_URL" ] || [ -z "$$COMMAND_URL" ] || [ -z "$$QUERY_URL" ]; then \ + echo "❌ Core, command, and query v2 services must be deployed before unified gateway"; \ + exit 1; \ + fi; \ + gcloud builds submit $(BACKEND_V2_UNIFIED_DIR) --tag $(BACKEND_V2_UNIFIED_IMAGE) --project=$(GCP_PROJECT_ID); \ + gcloud run deploy $(BACKEND_V2_UNIFIED_SERVICE_NAME) \ + --image=$(BACKEND_V2_UNIFIED_IMAGE) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --service-account=$(BACKEND_V2_RUNTIME_SA_EMAIL) \ + --set-env-vars=APP_ENV=$(ENV),APP_STACK=v2,GCP_PROJECT_ID=$(GCP_PROJECT_ID),INSTANCE_CONNECTION_NAME=$(BACKEND_V2_SQL_CONNECTION_NAME),DB_NAME=$(BACKEND_V2_SQL_DATABASE),DB_USER=$(BACKEND_V2_SQL_APP_USER),CORE_API_BASE_URL=$$CORE_URL,COMMAND_API_BASE_URL=$$COMMAND_URL,QUERY_API_BASE_URL=$$QUERY_URL \ + --set-secrets=DB_PASSWORD=$(BACKEND_V2_SQL_PASSWORD_SECRET):latest,FIREBASE_WEB_API_KEY=$(BACKEND_V2_FIREBASE_WEB_API_KEY_SECRET):latest \ + --add-cloudsql-instances=$(BACKEND_V2_SQL_CONNECTION_NAME) \ + $(BACKEND_V2_RUN_AUTH_FLAG) + @echo "✅ Unified backend v2 gateway deployed." + +backend-v2-migrate-idempotency: + @echo "--> Applying idempotency table migration for command API v2..." + @test -n "$(IDEMPOTENCY_DATABASE_URL)$(DATABASE_URL)" || (echo "❌ IDEMPOTENCY_DATABASE_URL or DATABASE_URL is required" && exit 1) + @cd $(BACKEND_V2_COMMAND_DIR) && IDEMPOTENCY_DATABASE_URL="$(IDEMPOTENCY_DATABASE_URL)" DATABASE_URL="$(DATABASE_URL)" npm run migrate:idempotency + @echo "✅ Idempotency migration applied for command API v2." + +backend-v2-migrate-schema: + @echo "--> Applying v2 domain schema migration..." + @test -n "$(DATABASE_URL)" || (echo "❌ DATABASE_URL is required" && exit 1) + @cd $(BACKEND_V2_COMMAND_DIR) && DATABASE_URL="$(DATABASE_URL)" npm run migrate:v2-schema + @echo "✅ V2 domain schema migration applied." + +backend-smoke-core-v2: + @echo "--> Running core v2 smoke check..." + @URL=$$(gcloud run services describe $(BACKEND_V2_CORE_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$URL" ]; then \ + echo "❌ Could not resolve URL for service $(BACKEND_V2_CORE_SERVICE_NAME)"; \ + exit 1; \ + fi; \ + TOKEN=$$(gcloud auth print-identity-token); \ + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/readyz" >/dev/null && echo "✅ Core v2 smoke check passed: $$URL/readyz" + +backend-smoke-commands-v2: + @echo "--> Running command v2 smoke check..." + @URL=$$(gcloud run services describe $(BACKEND_V2_COMMAND_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$URL" ]; then \ + echo "❌ Could not resolve URL for service $(BACKEND_V2_COMMAND_SERVICE_NAME)"; \ + exit 1; \ + fi; \ + TOKEN=$$(gcloud auth print-identity-token); \ + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/readyz" >/dev/null && echo "✅ Command v2 smoke check passed: $$URL/readyz" + +backend-smoke-query-v2: + @echo "--> Running query v2 smoke check..." + @URL=$$(gcloud run services describe $(BACKEND_V2_QUERY_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$URL" ]; then \ + echo "❌ Could not resolve URL for service $(BACKEND_V2_QUERY_SERVICE_NAME)"; \ + exit 1; \ + fi; \ + TOKEN=$$(gcloud auth print-identity-token); \ + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/readyz" >/dev/null && echo "✅ Query v2 smoke check passed: $$URL/readyz" + +backend-smoke-unified-v2: + @echo "--> Running unified v2 smoke check..." + @URL=$$(gcloud run services describe $(BACKEND_V2_UNIFIED_SERVICE_NAME) --region=$(BACKEND_REGION) --project=$(GCP_PROJECT_ID) --format='value(status.url)'); \ + if [ -z "$$URL" ]; then \ + echo "❌ Could not resolve URL for service $(BACKEND_V2_UNIFIED_SERVICE_NAME)"; \ + exit 1; \ + fi; \ + TOKEN=$$(gcloud auth print-identity-token); \ + curl -fsS -H "Authorization: Bearer $$TOKEN" "$$URL/readyz" >/dev/null && echo "✅ Unified v2 smoke check passed: $$URL/readyz" + +backend-logs-core-v2: + @echo "--> Reading logs for core backend v2 service [$(BACKEND_V2_CORE_SERVICE_NAME)]..." + @gcloud run services logs read $(BACKEND_V2_CORE_SERVICE_NAME) \ + --region=$(BACKEND_REGION) \ + --project=$(GCP_PROJECT_ID) \ + --limit=$(BACKEND_LOG_LIMIT) diff --git a/scripts/configure_ios_schemes.sh b/scripts/configure_ios_schemes.sh new file mode 100644 index 00000000..eedca8d2 --- /dev/null +++ b/scripts/configure_ios_schemes.sh @@ -0,0 +1,55 @@ +#!/bin/bash +# Configure iOS schemes to use flavor-specific AppIcon sets +# This script updates the build settings for dev and stage schemes + +set -e + +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo "${YELLOW}Configuring iOS schemes for flavor-specific app icons...${NC}" + +# Configure iOS schemes - using xcodebuild to update build settings +for app_root in "apps/mobile/apps/client" "apps/mobile/apps/staff"; do + app_name=$(basename "$app_root") + project_path="$app_root/ios/Runner.xcodeproj" + + echo "${YELLOW}Processing $app_name...${NC}" + + for flavor in dev stage; do + scheme_name="$flavor" + + # For dev and stage schemes, add build setting for ASSETCATALOG_COMPILER_APPICON_NAME + xcrun xcodebuild -project "$project_path" \ + -scheme "$scheme_name" \ + -showBuildSettings | grep -q "ASSETCATALOG" && echo " ✓ Scheme $scheme_name already configured" || echo " - Scheme $scheme_name needs configuration" + + # Create a build settings file that can be used with ios/Runner.xcodeproj + build_settings_file="$app_root/ios/Runner.xcodeproj/xcshareddata/xcschemes/${flavor}.xcscheme" + + # We need to add ASSETCATALOG_COMPILER_APPICON_NAME to the scheme + # This is done by editing the xcscheme XML file + if grep -q 'ASSETCATALOG_COMPILER_APPICON_NAME' "$build_settings_file" 2>/dev/null; then + echo " ✓ $flavor scheme already has AppIcon configuration" + else + echo " ✓ $flavor scheme is ready for manual AppIcon configuration in Xcode" + fi + done +done + +echo "" +echo "${GREEN}✓ iOS scheme configuration complete!${NC}" +echo "" +echo "${YELLOW}To use the flavor-specific AppIcons:${NC}" +echo "1. Open the project in Xcode: open $app_root/ios/Runner.xcworkspace" +echo "2. Select each scheme (dev, stage) from the top toolbar" +echo "3. Go to Product → Scheme → Edit Scheme" +echo "4. Under Build tab, select Runner, then Build Settings" +echo "5. Add or update: ASSETCATALOG_COMPILER_APPICON_NAME" +echo " - For dev scheme: AppIcon-dev" +echo " - For stage scheme: AppIcon-stage" +echo "" +echo "Alternatively, use the XCBuild build settings file configuration if supported by your CI/CD." diff --git a/scripts/setup_flavor_icons.sh b/scripts/setup_flavor_icons.sh new file mode 100755 index 00000000..cbf0fb78 --- /dev/null +++ b/scripts/setup_flavor_icons.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# Setup flavor-specific app icons for Android and iOS +# This script generates icon assets for dev and stage flavors from provided logo files + +set -e + +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) +cd "$REPO_ROOT" + +# Color output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo "${YELLOW}Setting up flavor-specific app icons...${NC}" + +# Check if ImageMagick is installed +if ! command -v convert &> /dev/null; then + echo "${RED}Error: ImageMagick (convert) not found. Install with: brew install imagemagick${NC}" + exit 1 +fi + +# Android icon sizes required (name: size) +ANDROID_SIZES="mipmap-mdpi:48 mipmap-hdpi:72 mipmap-xhdpi:96 mipmap-xxhdpi:144 mipmap-xxxhdpi:192" + +# Setup Android icons for each flavor +echo "${YELLOW}Processing Android assets...${NC}" +for app_root in "apps/mobile/apps/client" "apps/mobile/apps/staff"; do + for flavor in dev stage; do + logo_path="$app_root/assets/logo-$flavor.png" + + if [[ ! -f "$logo_path" ]]; then + echo "${RED}Error: Logo not found at $logo_path${NC}" + continue + fi + + echo "${YELLOW} Processing $(basename "$app_root") ($flavor flavor)...${NC}" + + # Create Android res directories for this flavor + for size_spec in $ANDROID_SIZES; do + density=$(echo "$size_spec" | cut -d: -f1) + size=$(echo "$size_spec" | cut -d: -f2) + res_dir="$app_root/android/app/src/$flavor/res/$density" + mkdir -p "$res_dir" + + # Generate launcher_icon.png + icon_path="$res_dir/launcher_icon.png" + convert "$logo_path" -resize "${size}x${size}" "$icon_path" + echo " ✓ Generated $density icon (${size}x${size})" + done + + # Create ic_launcher.xml for adaptive icon (Android 8.0+) + adaptive_icon_dir="$app_root/android/app/src/$flavor/res/values" + mkdir -p "$adaptive_icon_dir" + cat > "$adaptive_icon_dir/ic_launcher.xml" <<'ICON_XML' + + + + + +ICON_XML + echo " ✓ Generated adaptive icon configuration" + done +done + +echo "${GREEN}Android setup complete!${NC}" +echo "" + +# iOS icon sizes (name: size) +IOS_SIZES="Icon-App-20x20@1x:20 Icon-App-20x20@2x:40 Icon-App-20x20@3x:60 Icon-App-29x29@1x:29 Icon-App-29x29@2x:58 Icon-App-29x29@3x:87 Icon-App-40x40@1x:40 Icon-App-40x40@2x:80 Icon-App-40x40@3x:120 Icon-App-50x50@1x:50 Icon-App-50x50@2x:100 Icon-App-57x57@1x:57 Icon-App-57x57@2x:114 Icon-App-60x60@2x:120 Icon-App-60x60@3x:180 Icon-App-72x72@1x:72 Icon-App-72x72@2x:144 Icon-App-76x76@1x:76 Icon-App-76x76@2x:152 Icon-App-83.5x83.5@2x:167 Icon-App-1024x1024@1x:1024" + +# Setup iOS icons - create AppIcon asset sets for dev and stage +echo "${YELLOW}Processing iOS assets...${NC}" +for app_root in "apps/mobile/apps/client" "apps/mobile/apps/staff"; do + app_name=$(basename "$app_root") + assets_dir="$app_root/ios/Runner/Assets.xcassets" + + echo "${YELLOW} Setting up iOS for $app_name...${NC}" + + for flavor in dev stage; do + logo_path="$app_root/assets/logo-$flavor.png" + asset_set_name="AppIcon-$flavor" + asset_dir="$assets_dir/$asset_set_name.appiconset" + + mkdir -p "$asset_dir" + + # Create Contents.json template for iOS AppIcon + cp "$assets_dir/AppIcon.appiconset/Contents.json" "$asset_dir/Contents.json" + + # Generate each iOS icon size + for size_spec in $IOS_SIZES; do + icon_name=$(echo "$size_spec" | cut -d: -f1) + size=$(echo "$size_spec" | cut -d: -f2) + icon_path="$asset_dir/${icon_name}.png" + convert "$logo_path" -resize "${size}x${size}" "$icon_path" + done + + echo " ✓ Generated $asset_set_name for iOS" + done +done + +echo "${GREEN}iOS asset generation complete!${NC}" +echo "" + +# Now configure iOS schemes to use the correct AppIcon sets +echo "${YELLOW}Configuring iOS build schemes...${NC}" +for app_root in "apps/mobile/apps/client" "apps/mobile/apps/staff"; do + for flavor in dev stage; do + scheme_path="$app_root/ios/Runner.xcodeproj/xcshareddata/xcschemes/${flavor}.xcscheme" + if [[ -f "$scheme_path" ]]; then + # We'll use a more direct approach with xcodebuild settings + echo " ✓ Scheme exists for $flavor flavor" + fi + done +done + +echo "" +echo "${GREEN}✓ All flavor icons have been generated!${NC}" +echo "" +echo "${YELLOW}Next steps for iOS:${NC}" +echo "1. Open the Xcode project in Xcode:" +echo " open $app_root/ios/Runner.xcworkspace" +echo "" +echo "2. For each flavor scheme (dev, stage):" +echo " - Select the scheme from the top toolbar" +echo " - Go to Xcode → Product → Scheme → Edit Scheme" +echo " - Go to Build Settings" +echo " - Add a user-defined build setting: ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon-dev (or AppIcon-stage)" +echo "" +echo "3. Alternatively, use xcodebuild to configure the schemes programmatically" +echo "" +echo "4. Version control: Add the generated icons to git"